import { config } from '../global/config'; import { getIonMode } from '../global/ionic-global'; import type { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, FrameworkDelegate, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions, } from '../interface'; import { CoreDelegate } from './framework-delegate'; import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button'; import { addEventListener, componentOnReady, focusElement, getElementRoot, removeEventListener } from './helpers'; let lastId = 0; export const activeAnimations = new WeakMap(); const createController = (tagName: string) => { return { create(options: Opts): Promise { return createOverlay(tagName, options) as any; }, dismiss(data?: any, role?: string, id?: string) { return dismissOverlay(document, data, role, tagName, id); }, async getTop(): Promise { return getOverlay(document, tagName) as any; }, }; }; export const alertController = /*@__PURE__*/ createController('ion-alert'); export const actionSheetController = /*@__PURE__*/ createController( 'ion-action-sheet' ); export const loadingController = /*@__PURE__*/ createController('ion-loading'); export const modalController = /*@__PURE__*/ createController('ion-modal'); export const pickerController = /*@__PURE__*/ createController('ion-picker'); export const popoverController = /*@__PURE__*/ createController('ion-popover'); export const toastController = /*@__PURE__*/ createController('ion-toast'); export const prepareOverlay = (el: T) => { if (typeof document !== 'undefined') { connectListeners(document); } const overlayIndex = lastId++; el.overlayIndex = overlayIndex; if (!el.hasAttribute('id')) { el.id = `ion-overlay-${overlayIndex}`; } }; export const createOverlay = ( tagName: string, opts: object | undefined ): Promise => { if (typeof window !== 'undefined' && typeof window.customElements !== 'undefined') { return window.customElements.whenDefined(tagName).then(() => { const element = document.createElement(tagName) as HTMLIonOverlayElement; element.classList.add('overlay-hidden'); /** * Convert the passed in overlay options into props * that get passed down into the new overlay. */ Object.assign(element, { ...opts, hasController: true }); // append the overlay element to the document body getAppRoot(document).appendChild(element); return new Promise((resolve) => componentOnReady(element, resolve)); }); } return Promise.resolve() as any; }; /** * This query string selects elements that * are eligible to receive focus. We select * interactive elements that meet the following * criteria: * 1. Element does not have a negative tabindex * 2. Element does not have `hidden` * 3. Element does not have `disabled` for non-Ionic components. * 4. Element does not have `disabled` or `disabled="true"` for Ionic components. * Note: We need this distinction because `disabled="false"` is * valid usage for the disabled property on ion-button. */ const focusableQueryString = '[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])'; export const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => { let firstInput = ref.querySelector(focusableQueryString) as HTMLElement | null; const shadowRoot = firstInput?.shadowRoot; if (shadowRoot) { // If there are no inner focusable elements, just focus the host element. firstInput = shadowRoot.querySelector(focusableQueryString) || firstInput; } if (firstInput) { focusElement(firstInput); } else { // Focus overlay instead of letting focus escape overlay.focus(); } }; const isOverlayHidden = (overlay: Element) => overlay.classList.contains('overlay-hidden'); const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => { const inputs = Array.from(ref.querySelectorAll(focusableQueryString)) as HTMLElement[]; let lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null; const shadowRoot = lastInput?.shadowRoot; if (shadowRoot) { // If there are no inner focusable elements, just focus the host element. lastInput = shadowRoot.querySelector(focusableQueryString) || lastInput; } if (lastInput) { lastInput.focus(); } else { // Focus overlay instead of letting focus escape overlay.focus(); } }; /** * Traps keyboard focus inside of overlay components. * Based on https://w3c.github.io/aria-practices/examples/dialog-modal/alertdialog.html * This includes the following components: Action Sheet, Alert, Loading, Modal, * Picker, and Popover. * Should NOT include: Toast */ const trapKeyboardFocus = (ev: Event, doc: Document) => { const lastOverlay = getOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover'); const target = ev.target as HTMLElement | null; /** * If no active overlay, ignore this event. * * If this component uses the shadow dom, * this global listener is pointless * since it will not catch the focus * traps as they are inside the shadow root. * We need to add a listener to the shadow root * itself to ensure the focus trap works. */ if (!lastOverlay || !target) { return; } /** * If the ion-disable-focus-trap class * is present on an overlay, then this component * instance has opted out of focus trapping. * An example of this is when the sheet modal * has a backdrop that is disabled. The content * behind the sheet should be focusable until * the backdrop is enabled. */ if (lastOverlay.classList.contains('ion-disable-focus-trap')) { return; } const trapScopedFocus = () => { /** * If we are focusing the overlay, clear * the last focused element so that hitting * tab activates the first focusable element * in the overlay wrapper. */ if (lastOverlay === target) { lastOverlay.lastFocus = undefined; /** * Otherwise, we must be focusing an element * inside of the overlay. The two possible options * here are an input/button/etc or the ion-focus-trap * element. The focus trap element is used to prevent * the keyboard focus from leaving the overlay when * using Tab or screen assistants. */ } else { /** * We do not want to focus the traps, so get the overlay * wrapper element as the traps live outside of the wrapper. */ const overlayRoot = getElementRoot(lastOverlay); if (!overlayRoot.contains(target)) { return; } const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper'); if (!overlayWrapper) { return; } /** * If the target is inside the wrapper, let the browser * focus as normal and keep a log of the last focused element. */ if (overlayWrapper.contains(target)) { lastOverlay.lastFocus = target; } else { /** * Otherwise, we must have focused one of the focus traps. * We need to wrap the focus to either the first element * or the last element. */ /** * Once we call `focusFirstDescendant` and focus the first * descendant, another focus event will fire which will * cause `lastOverlay.lastFocus` to be updated before * we can run the code after that. We will cache the value * here to avoid that. */ const lastFocus = lastOverlay.lastFocus; // Focus the first element in the overlay wrapper focusFirstDescendant(overlayWrapper, lastOverlay); /** * If the cached last focused element is the * same as the active element, then we need * to wrap focus to the last descendant. This happens * when the first descendant is focused, and the user * presses Shift + Tab. The previous line will focus * the same descendant again (the first one), causing * last focus to equal the active element. */ if (lastFocus === doc.activeElement) { focusLastDescendant(overlayWrapper, lastOverlay); } lastOverlay.lastFocus = doc.activeElement as HTMLElement; } } }; const trapShadowFocus = () => { /** * If the target is inside the wrapper, let the browser * focus as normal and keep a log of the last focused element. */ if (lastOverlay.contains(target)) { lastOverlay.lastFocus = target; } else { /** * Otherwise, we are about to have focus * go out of the overlay. We need to wrap * the focus to either the first element * or the last element. */ /** * Once we call `focusFirstDescendant` and focus the first * descendant, another focus event will fire which will * cause `lastOverlay.lastFocus` to be updated before * we can run the code after that. We will cache the value * here to avoid that. */ const lastFocus = lastOverlay.lastFocus; // Focus the first element in the overlay wrapper focusFirstDescendant(lastOverlay, lastOverlay); /** * If the cached last focused element is the * same as the active element, then we need * to wrap focus to the last descendant. This happens * when the first descendant is focused, and the user * presses Shift + Tab. The previous line will focus * the same descendant again (the first one), causing * last focus to equal the active element. */ if (lastFocus === doc.activeElement) { focusLastDescendant(lastOverlay, lastOverlay); } lastOverlay.lastFocus = doc.activeElement as HTMLElement; } }; if (lastOverlay.shadowRoot) { trapShadowFocus(); } else { trapScopedFocus(); } }; const connectListeners = (doc: Document) => { if (lastId === 0) { lastId = 1; doc.addEventListener( 'focus', (ev: FocusEvent) => { trapKeyboardFocus(ev, doc); }, true ); // handle back-button click doc.addEventListener('ionBackButton', (ev) => { const lastOverlay = getOverlay(doc); if (lastOverlay?.backdropDismiss) { (ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => { return lastOverlay.dismiss(undefined, BACKDROP); }); } }); // handle ESC to close overlay doc.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { const lastOverlay = getOverlay(doc); if (lastOverlay?.backdropDismiss) { lastOverlay.dismiss(undefined, BACKDROP); } } }); } }; export const dismissOverlay = ( doc: Document, data: any, role: string | undefined, overlayTag: string, id?: string ): Promise => { const overlay = getOverlay(doc, overlayTag, id); if (!overlay) { return Promise.reject('overlay does not exist'); } return overlay.dismiss(data, role); }; export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayElement[] => { if (selector === undefined) { selector = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover,ion-toast'; } return (Array.from(doc.querySelectorAll(selector)) as HTMLIonOverlayElement[]).filter((c) => c.overlayIndex > 0); }; /** * Returns an overlay element * @param doc The document to find the element within. * @param overlayTag The selector for the overlay, defaults to Ionic overlay components. * @param id The unique identifier for the overlay instance. * @returns The overlay element or `undefined` if no overlay element is found. */ export const getOverlay = (doc: Document, overlayTag?: string, id?: string): HTMLIonOverlayElement | undefined => { const overlays = getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o)); return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id); }; /** * When an overlay is presented, the main * focus is the overlay not the page content. * We need to remove the page content from the * accessibility tree otherwise when * users use "read screen from top" gestures with * TalkBack and VoiceOver, the screen reader will begin * to read the content underneath the overlay. * * We need a container where all page components * exist that is separate from where the overlays * are added in the DOM. For most apps, this element * is the top most ion-router-outlet. In the event * that devs are not using a router, * they will need to add the "ion-view-container-root" * id to the element that contains all of their views. * * TODO: If Framework supports having multiple top * level router outlets we would need to update this. * Example: One outlet for side menu and one outlet * for main content. */ export const setRootAriaHidden = (hidden = false) => { const root = getAppRoot(document); const viewContainer = root.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); if (!viewContainer) { return; } if (hidden) { viewContainer.setAttribute('aria-hidden', 'true'); } else { viewContainer.removeAttribute('aria-hidden'); } }; export const present = async ( overlay: OverlayInterface, name: keyof IonicConfig, iosEnterAnimation: AnimationBuilder, mdEnterAnimation: AnimationBuilder, opts?: OverlayPresentOptions ) => { if (overlay.presented) { return; } setRootAriaHidden(true); overlay.presented = true; overlay.willPresent.emit(); overlay.willPresentShorthand?.emit(); const mode = getIonMode(overlay); // get the user's animation fn if one was provided const animationBuilder = overlay.enterAnimation ? overlay.enterAnimation : config.get(name, mode === 'ios' ? iosEnterAnimation : mdEnterAnimation); const completed = await overlayAnimation(overlay, animationBuilder, overlay.el, opts); if (completed) { overlay.didPresent.emit(); overlay.didPresentShorthand?.emit(); } /** * When an overlay that steals focus * is dismissed, focus should be returned * to the element that was focused * prior to the overlay opening. Toast * does not steal focus and is excluded * from returning focus as a result. */ if (overlay.el.tagName !== 'ION-TOAST') { focusPreviousElementOnDismiss(overlay.el); } /** * If the focused element is already * inside the overlay component then * focus should not be moved from that * to the overlay container. */ if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) { overlay.el.focus(); } }; /** * When an overlay component is dismissed, * focus should be returned to the element * that presented the overlay. Otherwise * focus will be set on the body which * means that people using screen readers * or tabbing will need to re-navigate * to where they were before they * opened the overlay. */ const focusPreviousElementOnDismiss = async (overlayEl: any) => { let previousElement = document.activeElement as HTMLElement | null; if (!previousElement) { return; } const shadowRoot = previousElement?.shadowRoot; if (shadowRoot) { // If there are no inner focusable elements, just focus the host element. previousElement = shadowRoot.querySelector(focusableQueryString) || previousElement; } await overlayEl.onDidDismiss(); previousElement.focus(); }; export const dismiss = async ( overlay: OverlayInterface, data: any | undefined, role: string | undefined, name: keyof IonicConfig, iosLeaveAnimation: AnimationBuilder, mdLeaveAnimation: AnimationBuilder, opts?: OverlayDismissOptions ): Promise => { if (!overlay.presented) { return false; } setRootAriaHidden(false); overlay.presented = false; try { // Overlay contents should not be clickable during dismiss overlay.el.style.setProperty('pointer-events', 'none'); overlay.willDismiss.emit({ data, role }); overlay.willDismissShorthand?.emit({ data, role }); const mode = getIonMode(overlay); const animationBuilder = overlay.leaveAnimation ? overlay.leaveAnimation : config.get(name, mode === 'ios' ? iosLeaveAnimation : mdLeaveAnimation); // If dismissed via gesture, no need to play leaving animation again if (role !== GESTURE) { await overlayAnimation(overlay, animationBuilder, overlay.el, opts); } overlay.didDismiss.emit({ data, role }); overlay.didDismissShorthand?.emit({ data, role }); activeAnimations.delete(overlay); /** * Make overlay hidden again in case it is being reused. * We can safely remove pointer-events: none as * overlay-hidden will set display: none. */ overlay.el.classList.add('overlay-hidden'); overlay.el.style.removeProperty('pointer-events'); } catch (err) { console.error(err); } overlay.el.remove(); return true; }; const getAppRoot = (doc: Document) => { return doc.querySelector('ion-app') || doc.body; }; const overlayAnimation = async ( overlay: OverlayInterface, animationBuilder: AnimationBuilder, baseEl: any, opts: any ): Promise => { // Make overlay visible in case it's hidden baseEl.classList.remove('overlay-hidden'); const aniRoot = overlay.el; const animation = animationBuilder(aniRoot, opts); if (!overlay.animated || !config.getBoolean('animated', true)) { animation.duration(0); } if (overlay.keyboardClose) { animation.beforeAddWrite(() => { const activeElement = baseEl.ownerDocument!.activeElement as HTMLElement; if (activeElement?.matches('input,ion-input, ion-textarea')) { activeElement.blur(); } }); } const activeAni = activeAnimations.get(overlay) || []; activeAnimations.set(overlay, [...activeAni, animation]); await animation.play(); return true; }; export const eventMethod = (element: HTMLElement, eventName: string): Promise => { let resolve: (detail: T) => void; const promise = new Promise((r) => (resolve = r)); onceEvent(element, eventName, (event: any) => { resolve(event.detail); }); return promise; }; export const onceEvent = (element: HTMLElement, eventName: string, callback: (ev: Event) => void) => { const handler = (ev: Event) => { removeEventListener(element, eventName, handler); callback(ev); }; addEventListener(element, eventName, handler); }; export const isCancel = (role: string | undefined): boolean => { return role === 'cancel' || role === BACKDROP; }; const defaultGate = (h: any) => h(); /** * Calls a developer provided method while avoiding * Angular Zones. Since the handler is provided by * the developer, we should throw any errors * received so that developer-provided bug * tracking software can log it. */ export const safeCall = (handler: any, arg?: any) => { if (typeof handler === 'function') { const jmp = config.get('_zoneGate', defaultGate); return jmp(() => { try { return handler(arg); } catch (e) { throw e; } }); } return undefined; }; export const BACKDROP = 'backdrop'; export const GESTURE = 'gesture'; /** * Creates a delegate controller. * * Requires that the component has the following properties: * - `el: HTMLElement` * - `hasController: boolean` * - `delegate?: FrameworkDelegate` * * @param ref The component class instance. */ export const createDelegateController = (ref: { el: HTMLElement; hasController: boolean; delegate?: FrameworkDelegate; }) => { let inline = false; let workingDelegate: FrameworkDelegate | undefined; const coreDelegate: FrameworkDelegate = CoreDelegate(); /** * Determines whether or not an overlay is being used * inline or via a controller/JS and returns the correct delegate. * By default, subsequent calls to getDelegate will use * a cached version of the delegate. * This is useful for calling dismiss after present, * so that the correct delegate is given. * @param force `true` to force the non-cached version of the delegate. * @returns The delegate to use and whether or not the overlay is inline. */ const getDelegate = (force = false) => { if (workingDelegate && !force) { return { delegate: workingDelegate, inline, }; } const { el, hasController, delegate } = ref; /** * If using overlay inline * we potentially need to use the coreDelegate * so that this works in vanilla JS apps. * If a developer has presented this component * via a controller, then we can assume * the component is already in the * correct place. */ const parentEl = el.parentNode as HTMLElement | null; inline = parentEl !== null && !hasController; workingDelegate = inline ? delegate || coreDelegate : delegate; return { inline, delegate: workingDelegate }; }; /** * Attaches a component in the DOM. Teleports the component * to the root of the app. * @param component The component to optionally construct and append to the element. */ const attachViewToDom = async (component?: any) => { const { delegate } = getDelegate(true); if (delegate) { return await delegate.attachViewToDom(ref.el, component); } const { hasController } = ref; if (hasController && component !== undefined) { throw new Error('framework delegate is missing'); } return null; }; /** * Moves a component back to its original location in the DOM. */ const removeViewFromDom = () => { const { delegate } = getDelegate(); if (delegate && ref.el !== undefined) { delegate.removeViewFromDom(ref.el.parentElement, ref.el); } }; return { attachViewToDom, removeViewFromDom, }; }; /** * Constructs a trigger interaction for an overlay. * Presents an overlay when the trigger is clicked. * * Usage: * ```ts * triggerController = createTriggerController(); * triggerController.addClickListener(el, trigger); * ``` */ export const createTriggerController = () => { let destroyTriggerInteraction: (() => void) | undefined; /** * Removes the click listener from the trigger element. */ const removeClickListener = (): void => { if (destroyTriggerInteraction) { destroyTriggerInteraction(); destroyTriggerInteraction = undefined; } }; /** * Adds a click listener to the trigger element. * Presents the overlay when the trigger is clicked. * @param el The overlay element. * @param trigger The ID of the element to add a click listener to. */ const addClickListener = (el: HTMLIonOverlayElement, trigger: string): void => { removeClickListener(); const triggerEl = trigger !== undefined ? document.getElementById(trigger) : null; if (!triggerEl) { return; } const configureTriggerInteraction = (targetEl: HTMLElement, overlayEl: HTMLIonOverlayElement) => { const openOverlay = () => { overlayEl.present(); }; targetEl.addEventListener('click', openOverlay); return () => { targetEl.removeEventListener('click', openOverlay); }; }; destroyTriggerInteraction = configureTriggerInteraction(triggerEl, el); }; return { addClickListener, removeClickListener, }; };