Last active
June 4, 2020 16:12
-
-
Save mattmccray/eb92f5bded5a55b001a0c2101dec00f5 to your computer and use it in GitHub Desktop.
DialogManager with support for bulma and animation via Animate.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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