import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Listen, Method, Prop, Watch, forceUpdate, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import type { AlertButton, AlertInput, AlertInputAttributes, AlertTextareaAttributes, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface, } from '../../interface'; import type { Gesture } from '../../utils/gesture'; import { createButtonActiveGesture } from '../../utils/gesture/button-active'; import { BACKDROP, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays'; import type { IonicSafeString } from '../../utils/sanitization'; import { sanitizeDOMString } from '../../utils/sanitization'; import { getClassMap } from '../../utils/theme'; import type { AlertAttributes } from './alert-interface'; import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. */ @Component({ tag: 'ion-alert', styleUrls: { ios: 'alert.ios.scss', md: 'alert.md.scss', }, scoped: true, }) export class Alert implements ComponentInterface, OverlayInterface { private activeId?: string; private inputType?: string; private processedInputs: AlertInput[] = []; private processedButtons: AlertButton[] = []; private wrapperEl?: HTMLElement; private gesture?: Gesture; presented = false; lastFocus?: HTMLElement; @Element() el!: HTMLIonAlertElement; /** @internal */ @Prop() overlayIndex!: number; /** * If `true`, the keyboard will be automatically dismissed when the overlay is presented. */ @Prop() keyboardClose = true; /** * Animation to use when the alert is presented. */ @Prop() enterAnimation?: AnimationBuilder; /** * Animation to use when the alert is dismissed. */ @Prop() leaveAnimation?: AnimationBuilder; /** * Additional classes to apply for custom CSS. If multiple classes are * provided they should be separated by spaces. */ @Prop() cssClass?: string | string[]; /** * The main title in the heading of the alert. */ @Prop() header?: string; /** * The subtitle in the heading of the alert. Displayed under the title. */ @Prop() subHeader?: string; /** * The main message to be displayed in the alert. * `message` can accept either plaintext or HTML as a string. * To display characters normally reserved for HTML, they * must be escaped. For example `` would become * `<Ionic>` * * For more information: [Security Documentation](https://ionicframework.com/docs/faq/security) */ @Prop() message?: string | IonicSafeString; /** * Array of buttons to be added to the alert. */ @Prop() buttons: (AlertButton | string)[] = []; /** * Array of input to show in the alert. */ @Prop({ mutable: true }) inputs: AlertInput[] = []; /** * If `true`, the alert will be dismissed when the backdrop is clicked. */ @Prop() backdropDismiss = true; /** * If `true`, the alert will be translucent. * Only applies when the mode is `"ios"` and the device supports * [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). */ @Prop() translucent = false; /** * If `true`, the alert will animate. */ @Prop() animated = true; /** * Additional attributes to pass to the alert. */ @Prop() htmlAttributes?: AlertAttributes; /** * Emitted after the alert has presented. */ @Event({ eventName: 'ionAlertDidPresent' }) didPresent!: EventEmitter; /** * Emitted before the alert has presented. */ @Event({ eventName: 'ionAlertWillPresent' }) willPresent!: EventEmitter; /** * Emitted before the alert has dismissed. */ @Event({ eventName: 'ionAlertWillDismiss' }) willDismiss!: EventEmitter; /** * Emitted after the alert has dismissed. */ @Event({ eventName: 'ionAlertDidDismiss' }) didDismiss!: EventEmitter; @Listen('keydown', { target: 'document' }) onKeydown(ev: any) { const inputTypes = new Set(this.processedInputs.map((i) => i.type)); // The only inputs we want to navigate between using arrow keys are the radios // ignore the keydown event if it is not on a radio button if ( !inputTypes.has('radio') || (ev.target && !this.el.contains(ev.target)) || ev.target.classList.contains('alert-button') ) { return; } // Get all radios inside of the radio group and then // filter out disabled radios since we need to skip those const query = this.el.querySelectorAll('.alert-radio') as NodeListOf; const radios = Array.from(query).filter((radio) => !radio.disabled); // The focused radio is the one that shares the same id as // the event target const index = radios.findIndex((radio) => radio.id === ev.target.id); // We need to know what the next radio element should // be in order to change the focus let nextEl: HTMLButtonElement | undefined; // If hitting arrow down or arrow right, move to the next radio // If we're on the last radio, move to the first radio if (['ArrowDown', 'ArrowRight'].includes(ev.code)) { nextEl = index === radios.length - 1 ? radios[0] : radios[index + 1]; } // If hitting arrow up or arrow left, move to the previous radio // If we're on the first radio, move to the last radio if (['ArrowUp', 'ArrowLeft'].includes(ev.code)) { nextEl = index === 0 ? radios[radios.length - 1] : radios[index - 1]; } if (nextEl && radios.includes(nextEl)) { const nextProcessed = this.processedInputs.find((input) => input.id === nextEl?.id); if (nextProcessed) { this.rbClick(nextProcessed); nextEl.focus(); } } } @Watch('buttons') buttonsChanged() { const buttons = this.buttons; this.processedButtons = buttons.map((btn) => { return typeof btn === 'string' ? { text: btn, role: btn.toLowerCase() === 'cancel' ? 'cancel' : undefined } : btn; }); } @Watch('inputs') inputsChanged() { const inputs = this.inputs; // Get the first input that is not disabled and the checked one // If an enabled checked input exists, set it to be the focusable input // otherwise we default to focus the first input // This will only be used when the input is type radio const first = inputs.find((input) => !input.disabled); const checked = inputs.find((input) => input.checked && !input.disabled); const focusable = checked || first; // An alert can be created with several different inputs. Radios, // checkboxes and inputs are all accepted, but they cannot be mixed. const inputTypes = new Set(inputs.map((i) => i.type)); if (inputTypes.has('checkbox') && inputTypes.has('radio')) { console.warn( `Alert cannot mix input types: ${Array.from(inputTypes.values()).join( '/' )}. Please see alert docs for more info.` ); } this.inputType = inputTypes.values().next().value; this.processedInputs = inputs.map( (i, index) => ({ type: i.type || 'text', name: i.name || `${index}`, placeholder: i.placeholder || '', value: i.value, label: i.label, checked: !!i.checked, disabled: !!i.disabled, id: i.id || `alert-input-${this.overlayIndex}-${index}`, handler: i.handler, min: i.min, max: i.max, cssClass: i.cssClass || '', attributes: i.attributes || {}, tabindex: i.type === 'radio' && i !== focusable ? -1 : 0, } as AlertInput) ); } connectedCallback() { prepareOverlay(this.el); } componentWillLoad() { this.inputsChanged(); this.buttonsChanged(); } disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; } } componentDidLoad() { /** * Do not create gesture if: * 1. A gesture already exists * 2. App is running in MD mode * 3. A wrapper ref does not exist */ if (this.gesture || getIonMode(this) === 'md' || !this.wrapperEl) { return; } this.gesture = createButtonActiveGesture(this.wrapperEl, (refEl: HTMLElement) => refEl.classList.contains('alert-button') ); this.gesture.enable(true); } /** * Present the alert overlay after it has been created. */ @Method() present(): Promise { return present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation); } /** * Dismiss the alert overlay after it has been presented. * * @param data Any data to emit in the dismiss events. * @param role The role of the element that is dismissing the alert. * This can be useful in a button handler for determining which button was * clicked to dismiss the alert. * Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. */ @Method() dismiss(data?: any, role?: string): Promise { return dismiss(this, data, role, 'alertLeave', iosLeaveAnimation, mdLeaveAnimation); } /** * Returns a promise that resolves when the alert did dismiss. */ @Method() onDidDismiss(): Promise> { return eventMethod(this.el, 'ionAlertDidDismiss'); } /** * Returns a promise that resolves when the alert will dismiss. */ @Method() onWillDismiss(): Promise> { return eventMethod(this.el, 'ionAlertWillDismiss'); } private rbClick(selectedInput: AlertInput) { for (const input of this.processedInputs) { input.checked = input === selectedInput; input.tabindex = input === selectedInput ? 0 : -1; } this.activeId = selectedInput.id; safeCall(selectedInput.handler, selectedInput); forceUpdate(this); } private cbClick(selectedInput: AlertInput) { selectedInput.checked = !selectedInput.checked; safeCall(selectedInput.handler, selectedInput); forceUpdate(this); } private buttonClick(button: AlertButton) { const role = button.role; const values = this.getValues(); if (isCancel(role)) { return this.dismiss({ values }, role); } const returnData = this.callButtonHandler(button, values); if (returnData !== false) { return this.dismiss({ values, ...returnData }, button.role); } return Promise.resolve(false); } private callButtonHandler(button: AlertButton | undefined, data?: any) { if (button?.handler) { // a handler has been provided, execute it // pass the handler the values from the inputs const returnData = safeCall(button.handler, data); if (returnData === false) { // if the return value of the handler is false then do not dismiss return false; } if (typeof returnData === 'object') { return returnData; } } return {}; } private getValues(): any { if (this.processedInputs.length === 0) { // this is an alert without any options/inputs at all return undefined; } if (this.inputType === 'radio') { // this is an alert with radio buttons (single value select) // return the one value which is checked, otherwise undefined const checkedInput = this.processedInputs.find((i) => !!i.checked); return checkedInput ? checkedInput.value : undefined; } if (this.inputType === 'checkbox') { // this is an alert with checkboxes (multiple value select) // return an array of all the checked values return this.processedInputs.filter((i) => i.checked).map((i) => i.value); } // this is an alert with text inputs // return an object of all the values with the input name as the key const values: { [k: string]: string } = {}; this.processedInputs.forEach((i) => { values[i.name!] = i.value || ''; }); return values; } private renderAlertInputs() { switch (this.inputType) { case 'checkbox': return this.renderCheckbox(); case 'radio': return this.renderRadio(); default: return this.renderInput(); } } private renderCheckbox() { const inputs = this.processedInputs; const mode = getIonMode(this); if (inputs.length === 0) { return null; } return (
{inputs.map((i) => ( ))}
); } private renderRadio() { const inputs = this.processedInputs; if (inputs.length === 0) { return null; } return (
{inputs.map((i) => ( ))}
); } private renderInput() { const inputs = this.processedInputs; if (inputs.length === 0) { return null; } return (
{inputs.map((i) => { if (i.type === 'textarea') { return (