import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Watch, Component, Element, Event, Host, Method, Prop, h, readTask } from '@stencil/core'; import type { Gesture } from '@utils/gesture'; import { createButtonActiveGesture } from '@utils/gesture/button-active'; import { raf } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { BACKDROP, createDelegateController, createTriggerController, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall, setOverlayId, } from '@utils/overlays'; import { getClassMap } from '@utils/theme'; import { getIonMode, getIonTheme } from '../../global/ionic-global'; import type { AnimationBuilder, CssClassMap, FrameworkDelegate, OverlayInterface } from '../../interface'; import type { OverlayEventDetail } from '../../utils/overlays-interface'; import type { ActionSheetButton } from './action-sheet-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 the platform behaviors of the component. * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. */ @Component({ tag: 'ion-action-sheet', styleUrls: { ios: 'action-sheet.ios.scss', md: 'action-sheet.md.scss', ionic: 'action-sheet.md.scss', }, scoped: true, }) export class ActionSheet implements ComponentInterface, OverlayInterface { private readonly delegateController = createDelegateController(this); private readonly lockController = createLockController(); private readonly triggerController = createTriggerController(); private wrapperEl?: HTMLElement; private groupEl?: HTMLElement; private gesture?: Gesture; presented = false; lastFocus?: HTMLElement; animation?: any; @Element() el!: HTMLIonActionSheetElement; /** @internal */ @Prop() overlayIndex!: number; /** @internal */ @Prop() delegate?: FrameworkDelegate; /** @internal */ @Prop() hasController = false; /** * If `true`, the keyboard will be automatically dismissed when the overlay is presented. */ @Prop() keyboardClose = true; /** * Animation to use when the action sheet is presented. */ @Prop() enterAnimation?: AnimationBuilder; /** * Animation to use when the action sheet is dismissed. */ @Prop() leaveAnimation?: AnimationBuilder; /** * An array of buttons for the action sheet. */ @Prop() buttons: (ActionSheetButton | string)[] = []; /** * Additional classes to apply for custom CSS. If multiple classes are * provided they should be separated by spaces. */ @Prop() cssClass?: string | string[]; /** * If `true`, the action sheet will be dismissed when the backdrop is clicked. */ @Prop() backdropDismiss = true; /** * Title for the action sheet. */ @Prop() header?: string; /** * Subtitle for the action sheet. */ @Prop() subHeader?: string; /** * If `true`, the action sheet will be translucent. * Only applies when the theme 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 action sheet will animate. */ @Prop() animated = true; /** * Additional attributes to pass to the action sheet. */ @Prop() htmlAttributes?: { [key: string]: any }; /** * If `true`, the action sheet will open. If `false`, the action sheet will close. * Use this if you need finer grained control over presentation, otherwise * just use the actionSheetController or the `trigger` property. * Note: `isOpen` will not automatically be set back to `false` when * the action sheet dismisses. You will need to do that in your code. */ @Prop() isOpen = false; @Watch('isOpen') onIsOpenChange(newValue: boolean, oldValue: boolean) { if (newValue === true && oldValue === false) { this.present(); } else if (newValue === false && oldValue === true) { this.dismiss(); } } /** * An ID corresponding to the trigger element that * causes the action sheet to open when clicked. */ @Prop() trigger: string | undefined; @Watch('trigger') triggerChanged() { const { trigger, el, triggerController } = this; if (trigger) { triggerController.addClickListener(el, trigger); } } /** * Emitted after the action sheet has presented. */ @Event({ eventName: 'ionActionSheetDidPresent' }) didPresent!: EventEmitter; /** * Emitted before the action sheet has presented. */ @Event({ eventName: 'ionActionSheetWillPresent' }) willPresent!: EventEmitter; /** * Emitted before the action sheet has dismissed. */ @Event({ eventName: 'ionActionSheetWillDismiss' }) willDismiss!: EventEmitter; /** * Emitted after the action sheet has dismissed. */ @Event({ eventName: 'ionActionSheetDidDismiss' }) didDismiss!: EventEmitter; /** * Emitted after the action sheet has presented. * Shorthand for ionActionSheetWillDismiss. */ @Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter; /** * Emitted before the action sheet has presented. * Shorthand for ionActionSheetWillPresent. */ @Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter; /** * Emitted before the action sheet has dismissed. * Shorthand for ionActionSheetWillDismiss. */ @Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter; /** * Emitted after the action sheet has dismissed. * Shorthand for ionActionSheetDidDismiss. */ @Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter; /** * Present the action sheet overlay after it has been created. */ @Method() async present(): Promise { const unlock = await this.lockController.lock(); await this.delegateController.attachViewToDom(); await present(this, 'actionSheetEnter', iosEnterAnimation, mdEnterAnimation); unlock(); } /** * Dismiss the action sheet 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 action sheet. * This can be useful in a button handler for determining which button was * clicked to dismiss the action sheet. * Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. * * This is a no-op if the overlay has not been presented yet. If you want * to remove an overlay from the DOM that was never presented, use the * [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method. */ @Method() async dismiss(data?: any, role?: string): Promise { const unlock = await this.lockController.lock(); const dismissed = await dismiss(this, data, role, 'actionSheetLeave', iosLeaveAnimation, mdLeaveAnimation); if (dismissed) { this.delegateController.removeViewFromDom(); } unlock(); return dismissed; } /** * Returns a promise that resolves when the action sheet did dismiss. */ @Method() onDidDismiss(): Promise> { return eventMethod(this.el, 'ionActionSheetDidDismiss'); } /** * Returns a promise that resolves when the action sheet will dismiss. * */ @Method() onWillDismiss(): Promise> { return eventMethod(this.el, 'ionActionSheetWillDismiss'); } private async buttonClick(button: ActionSheetButton) { const role = button.role; if (isCancel(role)) { return this.dismiss(button.data, role); } const shouldDismiss = await this.callButtonHandler(button); if (shouldDismiss) { return this.dismiss(button.data, button.role); } return Promise.resolve(); } private async callButtonHandler(button: ActionSheetButton | undefined) { if (button) { // a handler has been provided, execute it // pass the handler the values from the inputs const rtn = await safeCall(button.handler); if (rtn === false) { // if the return value of the handler is false then do not dismiss return false; } } return true; } private getButtons(): ActionSheetButton[] { return this.buttons.map((b) => { return typeof b === 'string' ? { text: b } : b; }); } private onBackdropTap = () => { this.dismiss(undefined, BACKDROP); }; private dispatchCancelHandler = (ev: CustomEvent) => { const role = ev.detail.role; if (isCancel(role)) { const cancelButton = this.getButtons().find((b) => b.role === 'cancel'); this.callButtonHandler(cancelButton); } }; connectedCallback() { prepareOverlay(this.el); this.triggerChanged(); } disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; } this.triggerController.removeClickListener(); } componentWillLoad() { if (!this.htmlAttributes?.id) { setOverlayId(this.el); } } componentDidLoad() { const mode = getIonMode(this); /** * Only create gesture if: * 1. A gesture does not already exist * 2. App is running in iOS mode * 3. A wrapper ref exists * 4. A group ref exists */ const { groupEl, wrapperEl } = this; if (!this.gesture && mode === 'ios' && wrapperEl && groupEl) { readTask(() => { const isScrollable = groupEl.scrollHeight > groupEl.clientHeight; if (!isScrollable) { this.gesture = createButtonActiveGesture(wrapperEl, (refEl: HTMLElement) => refEl.classList.contains('action-sheet-button') ); this.gesture.enable(true); } }); } /** * If action sheet was rendered with isOpen="true" * then we should open action sheet immediately. */ if (this.isOpen === true) { raf(() => this.present()); } /** * When binding values in frameworks such as Angular * it is possible for the value to be set after the Web Component * initializes but before the value watcher is set up in Stencil. * As a result, the watcher callback may not be fired. * We work around this by manually calling the watcher * callback when the component has loaded and the watcher * is configured. */ this.triggerChanged(); } render() { const { header, htmlAttributes, overlayIndex } = this; const theme = getIonTheme(this); const allButtons = this.getButtons(); const cancelButton = allButtons.find((b) => b.role === 'cancel'); const buttons = allButtons.filter((b) => b.role !== 'cancel'); const headerID = `action-sheet-${overlayIndex}-header`; return (
(this.wrapperEl = el)}>
(this.groupEl = el)}> {header !== undefined && (
{header} {this.subHeader &&
{this.subHeader}
}
)} {buttons.map((b) => ( ))}
{cancelButton && (
{/* Cancel buttons intentionally do not receive a disabled state here as we should not make it difficult to dismiss the overlay. */}
)}
); } } const buttonClass = (button: ActionSheetButton): CssClassMap => { return { 'action-sheet-button': true, 'ion-activatable': !button.disabled, 'ion-focusable': !button.disabled, [`action-sheet-${button.role}`]: button.role !== undefined, ...getClassMap(button.cssClass), }; };