UNPKG

@smui/dialog

Version:
450 lines (394 loc) 14.3 kB
/** * @license * Copyright 2017 Google Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import { AnimationFrame } from '@smui/common/animation/animationframe'; import { MDCFoundation } from '@smui/common/base/foundation'; import type { SpecificEventListener, SpecificWindowEventListener, } from '@smui/common/base/types'; import type { MDCDialogAdapter } from './adapter'; import { cssClasses, numbers, strings } from './constants'; import type { DialogConfigOptions } from './types'; enum AnimationKeys { POLL_SCROLL_POS = 'poll_scroll_position', POLL_LAYOUT_CHANGE = 'poll_layout_change', } /** MDC Dialog Foundation */ export class MDCDialogFoundation extends MDCFoundation<MDCDialogAdapter> { static override get cssClasses() { return cssClasses; } static override get strings() { return strings; } static override get numbers() { return numbers; } static override get defaultAdapter(): MDCDialogAdapter { return { addBodyClass: () => undefined, addClass: () => undefined, areButtonsStacked: () => false, clickDefaultButton: () => undefined, eventTargetMatches: () => false, getActionFromEvent: () => '', getInitialFocusEl: () => null, hasClass: () => false, isContentScrollable: () => false, notifyClosed: () => undefined, notifyClosing: () => undefined, notifyOpened: () => undefined, notifyOpening: () => undefined, releaseFocus: () => undefined, removeBodyClass: () => undefined, removeClass: () => undefined, reverseButtons: () => undefined, trapFocus: () => undefined, registerContentEventHandler: () => undefined, deregisterContentEventHandler: () => undefined, isScrollableContentAtTop: () => false, isScrollableContentAtBottom: () => false, registerWindowEventHandler: () => undefined, deregisterWindowEventHandler: () => undefined, }; } private dialogOpen = false; private isFullscreen = false; private animationFrame = 0; private animationTimer = 0; private escapeKeyAction = strings.CLOSE_ACTION; private scrimClickAction = strings.CLOSE_ACTION; private autoStackButtons = true; private areButtonsStacked = false; private suppressDefaultPressSelector = strings.SUPPRESS_DEFAULT_PRESS_SELECTOR; private readonly contentScrollHandler: SpecificEventListener<'scroll'>; private readonly animFrame: AnimationFrame; private readonly windowResizeHandler: SpecificWindowEventListener<'resize'>; private readonly windowOrientationChangeHandler: SpecificWindowEventListener<'orientationchange'>; constructor(adapter?: Partial<MDCDialogAdapter>) { super({ ...MDCDialogFoundation.defaultAdapter, ...adapter }); this.animFrame = new AnimationFrame(); this.contentScrollHandler = () => { this.handleScrollEvent(); }; this.windowResizeHandler = () => { this.layout(); }; this.windowOrientationChangeHandler = () => { this.layout(); }; } override init() { if (this.adapter.hasClass(cssClasses.STACKED)) { this.setAutoStackButtons(false); } this.isFullscreen = this.adapter.hasClass(cssClasses.FULLSCREEN); } override destroy() { if (this.animationTimer) { clearTimeout(this.animationTimer); this.handleAnimationTimerEnd(); } if (this.isFullscreen) { this.adapter.deregisterContentEventHandler( 'scroll', this.contentScrollHandler, ); } this.animFrame.cancelAll(); this.adapter.deregisterWindowEventHandler( 'resize', this.windowResizeHandler, ); this.adapter.deregisterWindowEventHandler( 'orientationchange', this.windowOrientationChangeHandler, ); } open(dialogOptions?: DialogConfigOptions) { this.dialogOpen = true; this.adapter.notifyOpening(); this.adapter.addClass(cssClasses.OPENING); if (this.isFullscreen) { // A scroll event listener is registered even if the dialog is not // scrollable on open, since the window resize event, or orientation // change may make the dialog scrollable after it is opened. this.adapter.registerContentEventHandler( 'scroll', this.contentScrollHandler, ); } if (dialogOptions && dialogOptions.isAboveFullscreenDialog) { this.adapter.addClass(cssClasses.SCRIM_HIDDEN); } this.adapter.registerWindowEventHandler('resize', this.windowResizeHandler); this.adapter.registerWindowEventHandler( 'orientationchange', this.windowOrientationChangeHandler, ); // Wait a frame once display is no longer "none", to establish basis for // animation this.runNextAnimationFrame(() => { this.adapter.addClass(cssClasses.OPEN); if (!dialogOptions || !dialogOptions.isScrimless) { this.adapter.addBodyClass(cssClasses.SCROLL_LOCK); } this.layout(); this.animationTimer = setTimeout(() => { this.handleAnimationTimerEnd(); this.adapter.trapFocus(this.adapter.getInitialFocusEl()); this.adapter.notifyOpened(); }, numbers.DIALOG_ANIMATION_OPEN_TIME_MS) as unknown as number; }); } close(action = '') { if (!this.dialogOpen) { // Avoid redundant close calls (and events), e.g. from keydown on elements // that inherently emit click return; } this.dialogOpen = false; this.adapter.notifyClosing(action); this.adapter.addClass(cssClasses.CLOSING); this.adapter.removeClass(cssClasses.OPEN); this.adapter.removeBodyClass(cssClasses.SCROLL_LOCK); if (this.isFullscreen) { this.adapter.deregisterContentEventHandler( 'scroll', this.contentScrollHandler, ); } this.adapter.deregisterWindowEventHandler( 'resize', this.windowResizeHandler, ); this.adapter.deregisterWindowEventHandler( 'orientationchange', this.windowOrientationChangeHandler, ); cancelAnimationFrame(this.animationFrame); this.animationFrame = 0; clearTimeout(this.animationTimer); this.animationTimer = setTimeout(() => { this.adapter.releaseFocus(); this.handleAnimationTimerEnd(); this.adapter.notifyClosed(action); }, numbers.DIALOG_ANIMATION_CLOSE_TIME_MS) as unknown as number; } /** * Used only in instances of showing a secondary dialog over a full-screen * dialog. Shows the "surface scrim" displayed over the full-screen dialog. */ showSurfaceScrim() { this.adapter.addClass(cssClasses.SURFACE_SCRIM_SHOWING); this.runNextAnimationFrame(() => { this.adapter.addClass(cssClasses.SURFACE_SCRIM_SHOWN); }); } /** * Used only in instances of showing a secondary dialog over a full-screen * dialog. Hides the "surface scrim" displayed over the full-screen dialog. */ hideSurfaceScrim() { this.adapter.removeClass(cssClasses.SURFACE_SCRIM_SHOWN); this.adapter.addClass(cssClasses.SURFACE_SCRIM_HIDING); } /** * Handles `transitionend` event triggered when surface scrim animation is * finished. */ handleSurfaceScrimTransitionEnd() { this.adapter.removeClass(cssClasses.SURFACE_SCRIM_HIDING); this.adapter.removeClass(cssClasses.SURFACE_SCRIM_SHOWING); } isOpen() { return this.dialogOpen; } getEscapeKeyAction(): string { return this.escapeKeyAction; } setEscapeKeyAction(action: string) { this.escapeKeyAction = action; } getScrimClickAction(): string { return this.scrimClickAction; } setScrimClickAction(action: string) { this.scrimClickAction = action; } getAutoStackButtons(): boolean { return this.autoStackButtons; } setAutoStackButtons(autoStack: boolean) { this.autoStackButtons = autoStack; } getSuppressDefaultPressSelector(): string { return this.suppressDefaultPressSelector; } setSuppressDefaultPressSelector(selector: string) { this.suppressDefaultPressSelector = selector; } layout() { this.animFrame.request(AnimationKeys.POLL_LAYOUT_CHANGE, () => { this.layoutInternal(); }); } /** Handles click on the dialog root element. */ handleClick(event: MouseEvent) { const isScrim = this.adapter.eventTargetMatches( event.target, strings.SCRIM_SELECTOR, ); // Check for scrim click first since it doesn't require querying ancestors. if (isScrim && this.scrimClickAction !== '') { this.close(this.scrimClickAction); } else { const action = this.adapter.getActionFromEvent(event); if (action) { this.close(action); } } } /** Handles keydown on the dialog root element. */ handleKeydown(event: KeyboardEvent) { const isEnter = event.key === 'Enter' || event.keyCode === 13; if (!isEnter) { return; } const action = this.adapter.getActionFromEvent(event); if (action) { // Action button callback is handled in `handleClick`, // since space/enter keydowns on buttons trigger click events. return; } // `composedPath` is used here, when available, to account for use cases // where a target meant to suppress the default press behaviour // may exist in a shadow root. // For example, a textarea inside a web component: // <mwc-dialog> // <horizontal-layout> // #shadow-root (open) // <mwc-textarea> // #shadow-root (open) // <textarea></textarea> // </mwc-textarea> // </horizontal-layout> // </mwc-dialog> const target = event.composedPath ? event.composedPath()[0] : event.target; const isDefault = this.suppressDefaultPressSelector ? !this.adapter.eventTargetMatches( target, this.suppressDefaultPressSelector, ) : true; if (isEnter && isDefault) { this.adapter.clickDefaultButton(); } } /** Handles keydown on the document. */ handleDocumentKeydown(event: KeyboardEvent) { const isEscape = event.key === 'Escape' || event.keyCode === 27; if (isEscape && this.escapeKeyAction !== '') { this.close(this.escapeKeyAction); } } /** * Handles scroll event on the dialog's content element -- showing a scroll * divider on the header or footer based on the scroll position. This handler * should only be registered on full-screen dialogs with scrollable content. */ private handleScrollEvent() { // Since scroll events can fire at a high rate, we throttle these events by // using requestAnimationFrame. this.animFrame.request(AnimationKeys.POLL_SCROLL_POS, () => { this.toggleScrollDividerHeader(); this.toggleScrollDividerFooter(); }); } private layoutInternal() { if (this.autoStackButtons) { this.detectStackedButtons(); } this.toggleScrollableClasses(); } private handleAnimationTimerEnd() { this.animationTimer = 0; this.adapter.removeClass(cssClasses.OPENING); this.adapter.removeClass(cssClasses.CLOSING); } /** * Runs the given logic on the next animation frame, using setTimeout to * factor in Firefox reflow behavior. */ private runNextAnimationFrame(callback: () => void) { cancelAnimationFrame(this.animationFrame); this.animationFrame = requestAnimationFrame(() => { this.animationFrame = 0; clearTimeout(this.animationTimer); this.animationTimer = setTimeout(callback, 0) as unknown as number; }); } private detectStackedButtons() { // Remove the class first to let us measure the buttons' natural positions. this.adapter.removeClass(cssClasses.STACKED); const areButtonsStacked = this.adapter.areButtonsStacked(); if (areButtonsStacked) { this.adapter.addClass(cssClasses.STACKED); } if (areButtonsStacked !== this.areButtonsStacked) { this.adapter.reverseButtons(); this.areButtonsStacked = areButtonsStacked; } } private toggleScrollableClasses() { // Remove the class first to let us measure the natural height of the // content. this.adapter.removeClass(cssClasses.SCROLLABLE); if (this.adapter.isContentScrollable()) { this.adapter.addClass(cssClasses.SCROLLABLE); if (this.isFullscreen) { // If dialog is full-screen and scrollable, check if a scroll divider // should be shown. this.toggleScrollDividerHeader(); this.toggleScrollDividerFooter(); } } } private toggleScrollDividerHeader() { if (!this.adapter.isScrollableContentAtTop()) { this.adapter.addClass(cssClasses.SCROLL_DIVIDER_HEADER); } else if (this.adapter.hasClass(cssClasses.SCROLL_DIVIDER_HEADER)) { this.adapter.removeClass(cssClasses.SCROLL_DIVIDER_HEADER); } } private toggleScrollDividerFooter() { if (!this.adapter.isScrollableContentAtBottom()) { this.adapter.addClass(cssClasses.SCROLL_DIVIDER_FOOTER); } else if (this.adapter.hasClass(cssClasses.SCROLL_DIVIDER_FOOTER)) { this.adapter.removeClass(cssClasses.SCROLL_DIVIDER_FOOTER); } } } // tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier. export default MDCDialogFoundation;