UNPKG

cyclejs-modal

Version:

An easy way to open custom modals in a cyclejs app

343 lines (308 loc) 11.3 kB
import xs, { Stream } from 'xstream'; import { VNode, h } from '@cycle/dom'; import isolate from '@cycle/isolate'; import { adapt } from '@cycle/run/lib/adapt'; import { mergeSinks, extractSinks } from 'cyclejs-utils'; export type Sinks = any; export type Sources = any; export type Component = (s: Sources) => Sinks; export interface Open { type: 'open'; component: Component; sources?: Sources; //To use proper isolation scopes backgroundOverlayClose?: boolean; //Default is true id?: string; //To allow for `select(id)` on the source, will not merge sinks at top level namespace?: Scope[]; //used internally } export interface Close { type: 'close'; count?: number; //Default is one namespace?: Scope[]; //used internally } export type ModalAction = Open | Close; export interface Options { name?: string; DOMDriverKey?: string; center?: boolean; modalContainerClass?: string; wrapperClass?: string; background?: string; zIndex?: number; } export type Scope = string | number; export interface SinksObject { id: string | undefined; namespace: Scope[]; sinks$: Stream<any>; } export class ModalSource { constructor( private _namespace: Scope[], private _sinks$$: Stream<SinksObject> = xs.create<SinksObject>() ) {} public select(id: string): ModalSource { return new ModalSource( this._namespace, this._sinks$$.filter(o => o.id !== undefined && o.id === id) ); } public sinks(): Stream<any>; public sinks(driverNames: string[]): any; public sinks(driverNames?: string[]): any | Stream<any> { if (driverNames) { return extractSinks(this.sinks(), driverNames); } return this._sinks$$.map(o => o.sinks$).flatten(); } public isolateSource(source: ModalSource, scope: Scope) { return new ModalSource( (source as any)._namespace.concat(scope), (source as any)._sinks$$ .filter(o => o.namespace[0] === scope) .map(o => ({ ...o, namespace: o.namespace.slice(1) })) ); } public isolateSink(modal$: Stream<ModalAction>, scope: Scope) { return modal$.map(action => ({ ...action, namespace: action.namespace ? action.namespace : this._namespace.concat(scope) })); } } type ModalStack = Array<[string | undefined, Scope[], Sinks]>; export function modalify( main: Component, { name = 'modal', DOMDriverKey = 'DOM', center = true, wrapperClass = null, modalContainerClass = null, background = 'rgba(0,0,0,0.8)', zIndex = 500 }: Options = {} ): Component { return function(sources: Sources): Sinks { const modalSource = new ModalSource([]); const parentSinks: Sinks = main({ ...sources, [name]: modalSource }); const sinks: Sinks = Object.keys(parentSinks) .map(k => ({ [k]: xs.fromObservable(parentSinks[k]) })) .reduce((prev, curr) => Object.assign(prev, curr), {}); if (sinks[name]) { const modalProxy$: Stream<ModalAction> = xs.create<ModalAction>(); const modalStack$: Stream<ModalStack> = xs .merge(sinks[name] as Stream<ModalAction>, modalProxy$) .fold((acc, curr) => { if (curr.type === 'close') { const count: number = curr.count || 1; for (let i = 0; i < Math.min(acc.length, count); i++) { const [id, namespace, _] = acc[acc.length - i - 1]; (modalSource as any)._sinks$$.shamefullySendNext({ id, namespace, sinks$: xs.never() }); } return acc.slice(0, Math.max(acc.length - count, 0)); } else if (curr.type === 'open') { const _sources: Sources = curr.sources !== undefined ? curr.sources : sources; let overlayClose$ = xs.never(); if (curr.backgroundOverlayClose !== false) { overlayClose$ = xs .fromObservable( sources[DOMDriverKey].select( 'div.cyclejs-modal' ).events('click') ) .map((ev: any) => { ev.stopPropagation(); return ev; }) .filter( (e: any) => e.target === (e.currentTarget || e.ownerTarget) ) .mapTo({ type: 'close' }); } const componentSinks: Sinks = curr.component(_sources); const xsComponentSinks: Sinks = Object.keys( componentSinks ) .map(k => ({ [k]: xs.fromObservable(componentSinks[k]) })) .reduce( (prev, curr) => Object.assign(prev, curr), {} ); const domlessSinks: Sinks = { ...componentSinks }; delete domlessSinks[DOMDriverKey]; (modalSource as any)._sinks$$.shamefullySendNext({ id: curr.id, namespace: curr.namespace || [], sinks$: xs.never().startWith(domlessSinks) }); return acc.concat([ [ curr.id, curr.namespace || [], { ...xsComponentSinks, modal: xs.merge( xsComponentSinks[name] || xs.never(), overlayClose$ ) } ] ]); } return acc; }, []); const modalVDom$: Stream<VNode[]> = modalStack$ .map<Stream<VNode>[]>(arr => arr.map(s => s[2][DOMDriverKey])) .map<Stream<VNode[]>>(arr => xs.combine(...arr)) .flatten(); const mergedVDom$: Stream<VNode> = xs .combine(sinks[DOMDriverKey] as Stream<VNode>, modalVDom$) .map<VNode>(([vdom, modals]) => h('div', { attrs: { class: wrapperClass || '' } }, [ vdom, center && modals.length > 0 ? displayModals( wrapModals(modals, modalContainerClass), background, zIndex ) : h( 'div', { attrs: { class: modalContainerClass || '' } }, modals ) ]) ); const extractedSinks: Sinks = extractSinks( modalStack$ .map(arr => arr.filter(s => s[0] === undefined).map(s => s[2]) ) .map<Sinks>(mergeSinks as any), Object.keys(sinks) ); modalProxy$.imitate(extractedSinks[name]); const newSinks = { ...mergeSinks([extractedSinks, sinks]), [DOMDriverKey]: mergedVDom$ }; return Object.keys(newSinks) .map(k => ({ [k]: adapt(newSinks[k]) })) .reduce((prev, curr) => Object.assign(prev, curr), {}); } return sinks; }; } export function centerHTML(children: VNode[]): VNode { return h( 'div', { style: { width: '100%', height: '100%', position: 'relative', 'pointer-events': 'none' } }, children.map(child => h( 'div', { style: { position: 'absolute', top: '50%', left: '50%', '-ms-transform': 'translate(-50%, -50%)', '-webkit-transform': 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)', 'pointer-events': 'auto' } }, [child] ) ) ); } function displayModals( modals: VNode[], background: string = 'rgba(0,0,0,0.8)', zIndex = 500 ): VNode { const processedModals: VNode[] = modals.map((m, i) => addStyles( { 'z-index': i * 5 + 10 + zIndex }, m ) ); return addStyles( { background, 'z-index': zIndex, top: '0px', left: '0px', position: 'fixed', width: '100%', height: '100%' }, h('div.cyclejs-modal', {}, [centerHTML(processedModals)]) ); } function wrapModals( modals: VNode[], containerClass: string | null = null ): VNode[] { const wrapper = child => h( 'div', containerClass ? { attrs: { class: containerClass } } : { style: { display: 'block', padding: '10px', background: 'white', width: 'auto', height: 'auto', 'border-radius': '5px' } }, [child] ); return modals.map(wrapper); } function addStyles(styles: { [k: string]: any }, vnode: VNode): VNode { return { ...vnode, data: { ...(vnode.data || {}), style: { ...(vnode.data.style || {}), ...styles } } }; }