Solid Dismiss
Handles "click outside" behavior for popup menu. Closing is triggered by click/focus outside of popup element or pressing "Escape" key. It can also deal with stacks/layers of popups.
Install
npm i solid-dismiss
yarn add solid-dismiss
pnpm add solid-dismiss
Example
import Dismiss from "solid-dismiss";
import { createSignal } from "solid-js";
const Popup = () => {
const [open, setOpen] = createSignal(false);
let btnEl;
return (
<div style="position: relative;">
<button ref={btnEl}>Open</button>
<Dismiss menuButton={btnEl} open={open} setOpen={setOpen}>
<div class="popup">
<p>Popup text!</p>
<p>
Lorem, <a href="#">ipsum</a> dolor.
</p>
</div>
</Dismiss>
</div>
);
};
Using SSR
Note: on solid-start version 0.1.1
and above, no need update vite config with ssr.noExternal, it happens automatically.
On SSR frameworks such as Astro or solid-start, you need to include ["solid-dismiss"]
value to the noExternal
property in the vite config file.
import solid from "solid-start/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [solid()],
ssr: {
noExternal: ["solid-dismiss"],
},
});
import { defineConfig } from "astro/config";
import solidJs from "@astrojs/solid-js";
export default defineConfig({
integrations: [solidJs()],
vite: {
ssr: {
noExternal: ["solid-dismiss"],
},
},
});
Caveat
For iOS Safari: when clicking outside, without overlay, and the element that happened to be clicked upon was an iframe, there's a chance that the popup won't close. iframe detection interaction is feasible by checking if window blurs, but in iOS, unless the user taps on a clickable element inside iframe, window won't blur because the main page focus hasn't been blurred.
If the iframe body element has click listener, then tapping anywhere on iframe will blur window, thus closing the popup as intended. Thus if author is dealing with same domain iframes, the author can easily add empty click event listener to the body.
const iframeEl = document.querySelector("iframe");
const doc = iframeEl.contentWindow.document;
doc.body.addEventListener("click", () => {});
Other
For better visual user experience, when using custom overlay elements in overlayElement prop,
<Dismiss
modal
overlayElement={{
element: <div style="position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.5)"/>
}}
extend the overlay's height (that has the value of viewport height either 100% or 100vh) by 100px or more. What happens is that on mobile devices, there's a lag of updating viewport dimensions when dynamic URL bar toggles, therefore showing a large gap at bottom of the page that the overlay doesn't cover. So the overlay's height should be calc(100vh + 100px)
or calc(100% + 100px)
, depending how you style the overlay, tailwind equivalent would be h-[calc(100vh+100px)]
.
<Dismiss
modal
overlayElement={{
element: <div style="position: fixed; inset: 0; height: calc(100vh + 100px); z-index: 1000; background: rgba(0, 0, 0, 0.5)"/>
}}
Docs
Dismiss
id?: string;
ref?: JSX.Element;
class?: string;
classList?: { [key: string]: boolean };
open: Accessor<boolean>;
setOpen: (v: boolean) => void;
onOpen?: OnOpenHandler;
menuButton:
| string
| JSX.Element
| Accessor<JSX.Element>
| (string | JSX.Element)[];
menuPopup?: string | JSX.Element | (() => JSX.Element);
cursorKeys?:
| boolean
| {
wrap: boolean;
onKeyDown?: (props: {
currentEl: HTMLElement | null;
prevEl: HTMLElement | null;
}) => void;
};
trapFocus?: boolean;
focusElementOnOpen?:
| "menuPopup"
| "firstChild"
| "none"
| JSX.Element
| (() => JSX.Element)
| {
target:
| "menuPopup"
| "firstChild"
| "none"
| JSX.Element
| (() => JSX.Element);
preventScroll?: boolean;
};
focusElementOnClose?:
| "menuButton"
| JSX.Element
| {
tabBackwards?: "menuButton" | JSX.Element;
tabForwards?: "menuButton" | JSX.Element;
click?: "menuButton" | JSX.Element;
escapeKey?: "menuButton" | JSX.Element;
scrolling?: "menuButton" | JSX.Element;
};
focusMenuButtonOnMouseDown?: boolean;
deadMenuButton?: boolean;
closeWhenMenuButtonIsTabbed?: boolean;
closeWhenMenuButtonIsClicked?: boolean;
closeWhenScrolling?: boolean;
closeWhenOverlayClicked?: boolean;
closeWhenEscapeKeyIsPressed?: boolean;
closeWhenDocumentBlurs?: boolean;
closeWhenClickingOutside?: boolean;
removeScrollbar?: boolean;
onToggleScrollbar?: {
onRemove: () => void;
onRestore: () => void;
};
overlay?: boolean;
overlayElement?:
| boolean
| {
ref?: (el: HTMLElement) => void;
class?: string;
classList?: { [key: string]: boolean };
animation?: DismissAnimation;
element?: JSX.Element;
};
modal?: boolean;
enableLastFocusSentinel?: boolean;
mount?: string | Node;
animation?: DismissAnimation;
show?: boolean;
ignoreMenuPopupWhenTabbing?: boolean;
mountedPopupsSafeList?: string[];
DismissAnimation
name?: string;
enterActiveClass?: string;
enterClass?: string;
enterToClass?: string;
exitActiveClass?: string;
exitClass?: string;
exitToClass?: string;
onBeforeEnter?: (el: Element) => void;
onEnter?: (el: Element, done: () => void) => void;
onAfterEnter?: (el: Element) => void;
onBeforeExit?: (el: Element) => void;
onExit?: (el: Element, done: () => void) => void;
onAfterExit?: (el: Element) => void;
appendToElement?: "menuPopup" | string | JSX.Element;
appear?: boolean;