import { Support } from './support'; import { Device } from './device'; import { Events, KeyboardEvents, ResizeEvents } from './events'; import { CupertinoSettings, PaneBreaks } from './models'; import { Settings } from './settings'; import { Breakpoints } from './breakpoints'; import { Transitions } from './transitions'; import { on, emit } from './events-emitter'; import * as Modules from './modules'; export class CupertinoPane { public disableDragEvents: boolean = false; public screen_height: number; public screenHeightOffset: number; public preventDismissEvent: boolean = false; public preventedDismiss: boolean = false; public rendered: boolean = false; public wrapperEl: HTMLDivElement; public paneEl: HTMLDivElement; public overflowEl: HTMLElement; public el: HTMLElement; public contentEl: HTMLElement; public parentEl: HTMLElement; public ionContent: HTMLElement; public ionApp: HTMLElement; public draggableEl: HTMLDivElement; public moveEl: HTMLDivElement; public currentTranslateY: number = 0; public currentTranslateX: number = 0; private styleEl: HTMLStyleElement; private destroyButtonEl: HTMLDivElement; private lastHideOnBottom?: boolean; private lastOverflowAuto?: boolean; public settings: CupertinoSettings = (new Settings()).instance; public device: Device = new Device(); public keyboardEvents: KeyboardEvents; public resizeEvents: ResizeEvents; public events: Events; public breakpoints: Breakpoints; public transitions: Transitions; public modules: {} = {}; // Events emitter public eventsListeners: {} = {}; public on: Function = on; public emit: Function = emit; // Temporary: modules public functions // should be moved under modules completely public calcFitHeight: (animated?: any) => Promise = () => { if (!this.settings.fitHeight) { console.warn(`Cupertino Pane: calcFitHeight() should be used for auto-height panes with enabled fitHeight option`); return null; } }; public backdrop: (conf: { show: true }) => void; public setZstackConfig: (zStack: any) => void; constructor(private selector: (string | HTMLElement), conf: CupertinoSettings = {}) { // Element or selector if (selector instanceof HTMLElement) { this.selector = selector; } else { this.selector = document.querySelector(selector); } // Unable attach selector or DOM element if (!this.selector) { console.warn('Cupertino Pane: wrong selector or DOM element specified', this.selector); return; } // Pane class created if (this.isPanePresented()) { console.error('Cupertino Pane: specified selector or DOM element already in use', this.selector); return; } this.el = this.selector; this.el.style.display = 'none'; this.settings = {...this.settings, ...conf}; // Get modules and collect settings let allModules = Object.keys(Modules).map((key) => Modules[key]); let modules = this.settings.modules || allModules; modules.forEach((module) => !!module.CollectSettings ? this.settings = module.CollectSettings(this.settings) : null); // Parent el as string or HTMLelement or get default element method let parentElement = this.el.parentElement; if (this.settings.parentElement) { parentElement = this.settings.parentElement instanceof HTMLElement ? this.settings.parentElement : document.querySelector(this.settings.parentElement); } this.settings.parentElement = parentElement; // Ion-content element if (this.device.ionic) { this.ionContent = document.querySelector('ion-content'); this.ionApp = document.querySelector('ion-app'); } // Events listeners if (this.settings.events) { Object.keys(this.settings.events).forEach( name => this.on(name, this.settings.events[name]) ); } // Core classes - Order matters! ResizeEvents needs to be before Events this.breakpoints = new Breakpoints(this); this.transitions = new Transitions(this); this.keyboardEvents = new KeyboardEvents(this); this.resizeEvents = new ResizeEvents(this); this.events = new Events(this); // Install modules modules.forEach((module) => this.modules[this.getModuleRef(module.name)] = new module(this)); } private drawBaseElements() { // Style element on head this.styleEl = document.createElement('style'); this.styleEl.id = `cupertino-pane-${(Math.random() + 1).toString(36).substring(7)}`; // Parent this.parentEl = this.settings.parentElement; // Wrapper this.wrapperEl = document.createElement('div'); this.wrapperEl.classList.add('cupertino-pane-wrapper'); if (this.settings.cssClass) { this.settings.cssClass.split(' ') .filter(item => !!item) .forEach(item => this.wrapperEl.classList.add(item)); }; let internalStyles: string = ''; internalStyles += ` .cupertino-pane-wrapper { display: none; position: absolute; top: 0; left: 0; } `; // Panel (appying transform ASAP, avoid timeouts for animate:true) this.paneEl = document.createElement('div'); this.paneEl.style.transform = this.buildTransform3d(0, this.screenHeightOffset, 0); this.currentTranslateY = this.screenHeightOffset; this.currentTranslateX = 0; this.paneEl.classList.add('pane'); internalStyles += ` .cupertino-pane-wrapper .pane { position: fixed; z-index: 11; width: 100%; max-width: 500px; left: 0px; right: 0px; margin-left: auto; margin-right: auto; background: var(--cupertino-pane-background, #ffffff); color: var(--cupertino-pane-color, #333333); box-shadow: var(--cupertino-pane-shadow, 0 4px 16px rgba(0,0,0,.12)); will-change: transform; padding-top: 15px; border-radius: var(--cupertino-pane-border-radius, 20px) var(--cupertino-pane-border-radius, 20px) 0 0; -webkit-user-select: none; } .cupertino-pane-wrapper .pane img { -webkit-user-drag: none; } `; // Draggable this.draggableEl = document.createElement('div'); this.draggableEl.classList.add('draggable'); if (this.settings.draggableOver) { this.draggableEl.classList.add('over'); } internalStyles += ` .cupertino-pane-wrapper .draggable { padding: 5px; position: absolute; left: 0; right: 0; margin-left: auto; margin-right: auto; height: 30px; z-index: -1; top: 0; bottom: initial; } .cupertino-pane-wrapper .draggable.over { top: -30px; padding: 15px; } `; // Move this.moveEl = document.createElement('div'); this.moveEl.classList.add('move'); internalStyles += ` .cupertino-pane-wrapper .move { margin: 0 auto; height: 5px; background: var(--cupertino-pane-move-background, #c0c0c0); width: 36px; border-radius: 4px; } .cupertino-pane-wrapper .draggable.over .move { width: 70px; background: var(--cupertino-pane-move-background, rgba(225, 225, 225, 0.6)); ${Support.backdropFilter ? ` backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); ` : ``} } `; // Destroy button this.destroyButtonEl = document.createElement('div'); this.destroyButtonEl.classList.add('destroy-button'); internalStyles += ` .cupertino-pane-wrapper .destroy-button { width: 26px; height: 26px; cursor: pointer; position: absolute; background: var(--cupertino-pane-destroy-button-background, #ebebeb); fill: var(--cupertino-pane-icon-close-color, #7a7a7e); right: 20px; z-index: 14; border-radius: 100%; top: 16px; } `; // Content user element this.contentEl = this.el; this.contentEl.style.transition = `opacity ${this.settings.animationDuration}ms ${this.settings.animationType} 0s`; this.contentEl.style.overflowX = 'hidden'; // Inject internal CSS this.styleEl.textContent = internalStyles.replace(/\s\s+/g, ' '); document.head.prepend(this.styleEl); // inject DOM this.parentEl.appendChild(this.wrapperEl); this.wrapperEl.appendChild(this.paneEl); this.paneEl.appendChild(this.contentEl); if (this.settings.showDraggable) { this.paneEl.appendChild(this.draggableEl); this.draggableEl.appendChild(this.moveEl); } // System event this.emit('DOMElementsReady'); } async present(conf: { animate: boolean, transition?: { duration?: number, from?: {}, to?: {}} } = { animate: false } ): Promise { if (!this.el || !document.body.contains(this.el)) { console.warn('Cupertino Pane: specified DOM element must be attached to the DOM'); return; } // Pane already exist and was rendered if (this.isPanePresented() && this.rendered) { this.moveToBreak(this.settings.initialBreak); return; } // Pane already exist but not rendered in this class if (this.isPanePresented() && !this.rendered) { console.warn('Cupertino Pane: specified selector or DOM element already in use', this.selector); return; } /** * Deal with Ionic Framework * Ionic cancel transition if the app is not ready * https://github.com/tech-systems/panes/issues/216 * Good to get rid of that, but Ionic team seems not * have a solution for this * https://github.com/ionic-team/ionic-framework/issues/27984 */ if (conf.animate && this.device.ionic) { // Ionic/React removed componentOnReady ??? if (this.ionApp['componentOnReady']) { await this.ionApp['componentOnReady'](); } await new Promise(resolve => requestAnimationFrame(resolve)); } // Emit event this.emit('onWillPresent'); this.updateScreenHeights(); this.drawBaseElements(); await this.setBreakpoints(); /** * Custom transitions for present/destroy functions * + Fix mutations on arguments */ let customTransitionFrom = conf?.transition?.from ? JSON.parse(JSON.stringify(conf.transition.from)) : null; if (customTransitionFrom) { if (!customTransitionFrom.transform) { customTransitionFrom.transform = this.buildTransform3d(0, this.breakpoints.breaks[this.settings.initialBreak], 0); } Object.assign(this.paneEl.style, customTransitionFrom); } // Show elements this.wrapperEl.style.display = 'block'; this.contentEl.style.display = 'block'; this.wrapperEl.classList.add('rendered'); this.rendered = true; // Init scroll (for some render DOM reasons important keep here for init) this.scrollElementInit(); // System event this.emit('rendered'); // Cursor this.setGrabCursor(true); // Button destroy if (this.settings.buttonDestroy) { this.paneEl.appendChild(this.destroyButtonEl); this.destroyButtonEl.addEventListener('click', (t) => this.destroy({animate:true, destroyButton: true})); this.destroyButtonEl.innerHTML = ` `; } // disable ion-content scroll-y if (this.device.ionic && !this.settings.ionContentScroll) { this.ionContent.setAttribute('scroll-y', 'false'); } if (this.settings.bottomClose) { this.settings.breaks.bottom.enabled = true; } if (this.settings.freeMode) { this.settings.lowerThanBottom = false; } /****** Fix android issues *******/ if (this.device.android) { // Body patch prevent android pull-to-refresh document.body.style['overscrollBehaviorY'] = 'none'; } // System event this.emit('beforePresentTransition', {animate: conf.animate}); // One frame before transition await new Promise(resolve => requestAnimationFrame(resolve)); if (conf.animate) { await this.transitions.doTransition({ type: 'present', conf, translateY: this.breakpoints.breaks[this.settings.initialBreak] }); } else { this.breakpoints.prevBreakpoint = this.settings.initialBreak; this.currentTranslateY = this.breakpoints.breaks[this.settings.initialBreak]; this.paneEl.style.transform = this.buildTransform3d(this.currentTranslateX, this.currentTranslateY, 0); } /****** Attach Events *******/ this.events.attachAllEvents(); // Emit event this.emit('onDidPresent', {animate: conf.animate} as any); return this; } public getPaneHeight(): number { return this.screen_height - this.breakpoints.topper - this.settings.bottomOffset; } public updateScreenHeights():void { this.screen_height = window.innerHeight; this.screenHeightOffset = window.innerHeight; } public scrollElementInit() { let attrElements = this.el.querySelectorAll('[overflow-y]'); if (!attrElements.length || attrElements.length > 1) { this.overflowEl = this.contentEl; } else { this.overflowEl = attrElements[0]; this.overflowEl.style.overflowX = 'hidden'; } this.overflowEl.style.overscrollBehavior = 'none'; this.setOverflowHeight(); } public setOverflowHeight(offset = 0) { this.paneEl.style.height = `${this.getPaneHeight()}px`; this.overflowEl.style.height = `${this.getPaneHeight() - this.settings.topperOverflowOffset - this.overflowEl.offsetTop - offset}px`; } public checkOpacityAttr(val) { let attrElements = this.el.querySelectorAll('[hide-on-bottom]'); if (!attrElements.length) return; const shouldHide = (val >= this.breakpoints.breaks['bottom']); if (this.lastHideOnBottom === shouldHide) return; attrElements.forEach((item) => { (item).style.transition = `opacity ${this.settings.animationDuration}ms ${this.settings.animationType} 0s`; (item).style.opacity = shouldHide ? '0' : '1'; }); this.lastHideOnBottom = shouldHide; } public checkOverflowAttr(val) { if (!this.settings.topperOverflow || !this.overflowEl) { return; } const shouldAuto = (val <= this.breakpoints.topper); if (this.lastOverflowAuto === shouldAuto) return; this.overflowEl.style.overflowY = shouldAuto ? 'auto' : 'hidden'; this.lastOverflowAuto = shouldAuto; // Update cursor immediately when scrollability changes this.setGrabCursor(true, false); } // TODO: replace with body.contains() public isPanePresented():boolean { // Check through all presented panes let wrappers = Array.from(document.querySelectorAll(`.cupertino-pane-wrapper.rendered`)); if (!wrappers.length) return false; return wrappers.find((item) => item.contains(this.selector)) ? true: false; } private prepareBreaksSwipeNextPoint(): {brs: {}, settingsBreaks: {}} { return { brs: {...this.breakpoints.breaks}, settingsBreaks: {...this.settings.breaks} }; } public swipeNextPoint = (diff, maxDiff, closest) => { const brs: any = this.breakpoints.breaks; const settingsBreaks: any = this.settings.breaks; const curr = this.breakpoints.currentBreakpoint; const topY = brs['top']; const midY = brs['middle']; const botY = brs['bottom']; if (curr === topY) { if (diff > maxDiff) { if (settingsBreaks['middle']?.enabled) return midY; if (settingsBreaks['bottom']?.enabled) return botY; } return topY; } if (curr === midY) { if (diff < -maxDiff && settingsBreaks['top']?.enabled) return topY; if (diff > maxDiff && settingsBreaks['bottom']?.enabled) return botY; return midY; } if (curr === botY) { if (diff < -maxDiff) { if (settingsBreaks['middle']?.enabled) return midY; if (settingsBreaks['top']?.enabled) return topY; } return botY; } return closest; } /** * Utility function to add minified internal CSS to head. * @param {string} styleString */ public addStyle(styleString): void { this.styleEl.textContent += styleString.replace(/\s\s+/g, ' '); }; /** * Utility function to build transform3d string for better performance * @param {number} x - X translation in pixels * @param {number} y - Y translation in pixels * @param {number} z - Z translation in pixels (defaults to 0) */ public buildTransform3d(x: number = 0, y: number = 0, z: number = 0): string { return `translate3d(${x}px, ${y}px, ${z}px)`; } /** * Utility function to build transform3d with scale for better performance * @param {number} x - X translation in pixels * @param {number} y - Y translation in pixels * @param {number} z - Z translation in pixels (defaults to 0) * @param {number} scale - Scale factor (defaults to 1) */ public buildTransform3dWithScale(x: number = 0, y: number = 0, z: number = 0, scale: number = 1): string { return `translate3d(${x}px, ${y}px, ${z}px) scale(${scale})`; } /** * Modern utility to parse transform3d values from computed style * Replaces WebKitCSSMatrix for better performance * @param {HTMLElement} element - Element to get transform from * @returns {object} Object with x, y, z translation values */ public parseTransform3d(element: HTMLElement): {x: number, y: number, z: number} { const transform = window.getComputedStyle(element).transform; if (transform === 'none' || !transform) { return {x: 0, y: 0, z: 0}; } // Handle matrix3d() format if (transform.startsWith('matrix3d(')) { const values = transform.slice(9, -1).split(',').map(v => parseFloat(v.trim())); return { x: values[12] || 0, y: values[13] || 0, z: values[14] || 0 }; } // Handle matrix() format (2D) if (transform.startsWith('matrix(')) { const values = transform.slice(7, -1).split(',').map(v => parseFloat(v.trim())); return { x: values[4] || 0, y: values[5] || 0, z: 0 }; } // Fallback for translate3d() format const translate3dMatch = transform.match(/translate3d\(([^,]+),\s*([^,]+),\s*([^)]+)\)/); if (translate3dMatch) { return { x: parseFloat(translate3dMatch[1]) || 0, y: parseFloat(translate3dMatch[2]) || 0, z: parseFloat(translate3dMatch[3]) || 0 }; } return {x: 0, y: 0, z: 0}; } private getModuleRef(className): string { return (className.charAt(0).toLowerCase() + className.slice(1)).replace('Module',''); } /************************************ * Public user methods */ public getPanelTransformY():number { return this.currentTranslateY; } // TODO: merge to 1 function above public getPanelTransformX():number { return this.currentTranslateX; } /** * Prevent dismiss event */ public preventDismiss(val: boolean = false): void { this.preventDismissEvent = val; } /** * GrabCursor for desktop */ public setGrabCursor(enable: boolean, moving?: boolean) { if (!this.device.desktop) { return; } const handleCursor = enable ? (moving ? 'grabbing' : 'grab') : ''; const isScrollableVisible = !!this.overflowEl && this.overflowEl.style.overflowY === 'auto' && this.overflowEl.scrollHeight > this.overflowEl.clientHeight; if (!enable) { this.paneEl.style.cursor = ''; if (this.draggableEl) this.draggableEl.style.cursor = ''; return; } // When content is scrollable, only the draggable handle shows grab/grabbing if (isScrollableVisible) { this.paneEl.style.cursor = ''; if (this.draggableEl) this.draggableEl.style.cursor = handleCursor; return; } // Default behavior: set cursor on the whole pane (and handle) this.paneEl.style.cursor = handleCursor; if (this.draggableEl) this.draggableEl.style.cursor = handleCursor; } /** * Disable pane drag events */ public disableDrag(): void { this.disableDragEvents = true; this.setGrabCursor(false); } /** * Enable pane drag events */ public enableDrag(): void { this.disableDragEvents = false; this.setGrabCursor(true); } /** * Public user method to reset breakpoints * @param conf */ public async setBreakpoints(conf?: PaneBreaks, bottomOffset?: number) { if (this.isPanePresented() && !conf) { console.warn(`Cupertino Pane: Provide any breaks configuration`); return; } await this.breakpoints.buildBreakpoints(conf, bottomOffset); } public async moveToBreak(val: string, type: string = 'breakpoint'): Promise { if (!this.isPanePresented()) { console.warn(`Cupertino Pane: Present pane before call moveToBreak()`); return null; } if (!this.settings.breaks[val].enabled) { console.warn('Cupertino Pane: %s breakpoint disabled', val); return; } this.checkOpacityAttr(this.breakpoints.breaks[val]); this.checkOverflowAttr(this.breakpoints.breaks[val]); await this.transitions.doTransition({type, translateY: this.breakpoints.breaks[val]}); this.breakpoints.currentBreakpoint = this.breakpoints.breaks[val]; return Promise.resolve(true); } public async moveToHeight(val: number): Promise { if (!this.isPanePresented()) { console.warn(`Cupertino Pane: Present pane before call moveToHeight()`); return null; } let translateY = this.screenHeightOffset ? this.screen_height - val : val; this.checkOpacityAttr(translateY); await this.transitions.doTransition({type: 'breakpoint', translateY }); } public async hide() { if (!this.isPanePresented()) { console.warn(`Cupertino Pane: Present pane before call hide()`); return null; } if (this.isHidden()) { console.warn(`Cupertino Pane: Pane already hidden`); return null; } await this.transitions.doTransition({type: 'hide', translateY: this.screenHeightOffset}); } public isHidden(): (boolean|null) { if (!this.isPanePresented()) { console.warn(`Cupertino Pane: Present pane before call isHidden()`); return null; } return this.transitions.isPaneHidden; } public currentBreak(): (string|null) { if (!this.isPanePresented()) { console.warn(`Cupertino Pane: Present pane before call currentBreak()`); return null; } return this.breakpoints.getCurrentBreakName(); }; public async destroy(conf: { animate: boolean, destroyButton?: boolean, transition?: { duration?: number, from?: {}, to?: {}} } = { animate: false, destroyButton: false }): Promise { // Experimentally allow to destroy, even if not currently in DOM, // instead of this.isPanePresented() check with rendered (#163 issue) if (!this.rendered) { console.warn(`Cupertino Pane: Present pane before call destroy()`); return null; } // Prevent dismiss if (this.preventDismissEvent) { // Emit event with prevent dismiss if not already sent from drag event if (!this.preventedDismiss) { this.emit('onWillDismiss', {prevented: true} as any); this.moveToBreak(this.breakpoints.prevBreakpoint); } return; } // Emit event this.emit('onWillDismiss'); /****** Animation & Transition ******/ if (conf.animate) { await this.transitions.doTransition({ type: 'destroy', conf, translateY: this.screenHeightOffset, destroyButton: conf.destroyButton }); } else { this.destroyResets(); } // Emit event this.emit('onDidDismiss', {destroyButton: conf.destroyButton} as any); } public destroyResets(): void { this.keyboardEvents.fixBodyKeyboardResize(false); this.parentEl.appendChild(this.contentEl); this.wrapperEl.remove(); this.styleEl.remove(); /****** Detach Events *******/ this.events.detachAllEvents(); // Reset vars delete this.rendered; delete this.breakpoints.prevBreakpoint; // Reset styles this.contentEl.style.display = 'none'; } }