Skip to content

Instantly share code, notes, and snippets.

@mattmccray
Last active June 4, 2020 16:12
Show Gist options
  • Save mattmccray/eb92f5bded5a55b001a0c2101dec00f5 to your computer and use it in GitHub Desktop.
Save mattmccray/eb92f5bded5a55b001a0c2101dec00f5 to your computer and use it in GitHub Desktop.
DialogManager with support for bulma and animation via Animate.css
import React, { useCallback, createContext, useEffect, createRef, Fragment } from 'react'
import { createPortal } from 'react-dom'
import { store, view } from '@risingstack/react-easy-state'
const DIALOG_CANCEL = Symbol("Dialog(cancel)")
const INTERNALS = Symbol("Dialog(internals)")
const DialogContext = createContext()
const state = store({
dialogs: [],
get isActive() {
return state.dialogs.filter(d => !d.isClosing).length > 0
},
get topmostDialog() {
if (!state.isActive) return null
let idx = state.dialogs.length - 1
let dialog = state.dialogs[idx]
while (idx >= 0 && dialog.isClosing) {
idx = idx - 1
dialog = state.dialogs[idx]
}
return dialog && dialog.isClosing ? null : dialog
},
add: (dialog) => {
state.dialogs.push(dialog)
},
remove: (idOrDialog) => {
const id = typeof idOrDialog == 'string' ? idOrDialog : idOrDialog.id
const dlg = state.dialogs.find(d => d.id === id)
const idx = state.dialogs.indexOf(dlg)
dlg.isClosing = true
setTimeout(() => {
state.dialogs.splice(idx, 1)
}, 500)
}
})
export async function openDialog(component, props = {}, options = {}) {
const dialog = createDialogState(component, props, options)
state.add(dialog)
return dialog[INTERNALS].returningPromise
}
function createDialogState(component, props, options) {
const id = uid()
const promiseHandler = {}
const returningPromise = new Promise((resolve, reject) => {
Object.assign(promiseHandler, { resolve, reject })
}).finally(() => {
state.remove(id)
})
return {
id, component, props, options,
get isTopmost() {
return state.topmostDialog && state.topmostDialog.id === id
},
isClosing: false,
returnValue(value = DIALOG_CANCEL) {
promiseHandler.resolve(value)
},
returnError(error) {
promiseHandler.reject(error)
},
[INTERNALS]: {
promiseHandler,
returningPromise,
}
}
}
export const DialogManager = view(() => {
const handleClose = useCallback(() => {
state.topmostDialog && state.topmostDialog.returnValue(null)
}, [])
if (state.dialogs.length === 0) return null
return createPortal(
<div className="modal is-active">
{state.isActive && <div className="modal-background"></div>}
{state.dialogs.map(dialog => (
<DialogContainer key={dialog.id} dialog={dialog} />
))}
<button onClick={handleClose} className="modal-close is-large" aria-label="close"></button>
</div>,
document.body
)
})
export default DialogManager
const DialogContainer = view(({ dialog }) => {
const { id, component: Component, props: dialogProps, isTopmost, isClosing } = dialog
const containerClsName = isTopmost
? 'animate__animated animate__fadeIn animate__faster'
: isClosing
? 'animate__animated animate__fadeOut animate__faster no-interaction'
: 'display-none'
return (
<div className={containerClsName} key={id} style={{ position: 'absolute' }}>
<DialogContextProvider key={dialog.id} dialog={dialog}>
<ContainFocus active={isTopmost}>
<Component
id={id}
isTopmost={isTopmost}
dialog={dialog}
{...dialogProps}
/>
</ContainFocus>
</DialogContextProvider>
</div>
)
})
const DialogContextProvider = (props) => (
<DialogContext.Provider value={props.dialog}>
{props.children}
</DialogContext.Provider>
)
export const ContainFocus = ({ active, children }) => {
const root = createRef()
useEffect(() => {
if (!active) return
const handler = (e) => {
if (!e.target || !root.current || _ignoreFocusChanges) return
if (!root.current.contains(e.target)) {
focusFirstDescendant(root.current)
}
}
if (!focusFirstDescendant(root.current)) {
attemptFocus(root.current, true)
}
window.addEventListener('focus', handler, true)
return () => {
window.removeEventListener('focus', handler, true)
}
}, [root, active])
const handleFocus = useCallback((e) => {
focusLastDescendant(root.current)
e.preventDefault()
e.stopPropagation()
}, [root])
return (
<Fragment>
<div tabIndex={0} onFocusCapture={handleFocus}></div>
<div ref={root} tabIndex={-1}>
{children}
</div>
<div tabIndex={0}></div>
</Fragment>
)
}
export function focusFirstDescendant(element) {
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (attemptFocus(child) || focusFirstDescendant(child)) {
return true;
}
}
return false;
}
export function focusLastDescendant(element) {
for (var i = element.childNodes.length - 1; i >= 0; i--) {
var child = element.childNodes[i];
if (attemptFocus(child) || focusLastDescendant(child)) {
return true;
}
}
return false;
}
let _ignoreFocusChanges = false
export function attemptFocus(element, force) {
if (!isFocusable(element) && !force) {
return false;
}
_ignoreFocusChanges = true;
try {
element.focus();
}
catch (e) { }
_ignoreFocusChanges = false;
return (document.activeElement === element);
}
function isFocusable(node) {
const element = node
if (element.tabIndex > 0 || (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null)) {
return true;
}
if (element.disabled) {
return false;
}
switch (element.nodeName) {
case 'A':
return !!element.href && element.rel !== 'ignore';
case 'INPUT':
return element.type !== 'hidden' && element.type !== 'file';
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true;
default:
return false;
}
}
function uid() {
let nextId = Date.now()
while (nextId <= uid._lastId) {
nextId = nextId + 1
}
uid._lastId = nextId
return nextId.toString(36)
}
uid._lastId = 0
import React from 'react'
import DialogManager, { openDialog } from './DialogManager'
export default () => (
<div>
<p>Dialog Manager.</p>
<button className="button" onClick={() => {
openDialog(Testing)
}}>Bang</button>
<DialogManager/>
</div>
)
const Testing = (props) => (
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">🏠 {props.label}</p>
</header>
<section className="modal-card-body">
<p>I'm a {props.label} test! {props.isTopmost ? 'ON TOP' : ''} - id:{props.id}</p>
<div>
<label><input className="input" type="text" placeholder="First" /></label><br />
<label><input className="input" type="text" placeholder="Second" /></label><br />
<label><input className="input" type="text" placeholder="Third" /></label><br />
<label><input className="input" type="text" placeholder="Forth" /></label><br />
</div>
<button className="button" onClick={() => {
openDialog(Testing, { label: `${props.label || ''} > ${props.id}` })
}}>Open Child Dialog</button>
</section>
<footer className="modal-card-foot">
<button className="button is-success" onClick={() => props.dialog.returnValue(true)} >Save changes</button>
<button className="button" onClick={() => props.dialog.returnValue(false)} >Cancel</button>
</footer>
</div>
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment