| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */ |
| |
| import * as i18n from '../../../core/i18n/i18n.js'; |
| import * as Platform from '../../../core/platform/platform.js'; |
| import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; |
| import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js'; |
| import * as Lit from '../../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; |
| import * as UI from '../../legacy/legacy.js'; |
| import * as Buttons from '../buttons/buttons.js'; |
| |
| import dialogStyles from './dialog.css.js'; |
| |
| const {html} = Lit; |
| |
| const UIStrings = { |
| |
| /** |
| * @description Title of close button for the shortcuts dialog. |
| */ |
| close: 'Close', |
| } as const; |
| |
| const str_ = i18n.i18n.registerUIStrings('ui/components/dialogs/Dialog.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| const IS_DIALOG_SUPPORTED = 'HTMLDialogElement' in globalThis; |
| |
| /** |
| * Height in pixels of the dialog's connector. The connector is represented as |
| * as a diamond and the height corresponds to half the height of the diamond. |
| * (the visible height is only half of the diamond). |
| **/ |
| export const CONNECTOR_HEIGHT = 10; |
| const CONNECTOR_WIDTH = 2 * CONNECTOR_HEIGHT; |
| |
| // The offset used by the dialog's animation as it slides in when opened. |
| const DIALOG_ANIMATION_OFFSET = 20; |
| |
| export const DIALOG_SIDE_PADDING = 5; |
| export const DIALOG_VERTICAL_PADDING = 3; |
| |
| /** |
| * If the content of the dialog cannot be completely shown because otherwise |
| * the dialog would overflow the window, the dialog's max width and height are |
| * set such that the dialog remains inside the visible bounds. In this cases |
| * some extra, determined by this constant, is added so that the dialog's borders |
| * remain clearly visible. This constant accounts for the padding of the dialog's |
| * content (20 px) and a 5px gap left on each extreme of the dialog from the viewport. |
| **/ |
| export const DIALOG_PADDING_FROM_WINDOW = 3 * CONNECTOR_HEIGHT; |
| interface DialogData { |
| /** |
| * Position or point the dialog is shown relative to. |
| * If the dialog instance will be shown as a modal, set |
| * this property to MODAL. |
| */ |
| origin: DialogOrigin; |
| position: DialogVerticalPosition; |
| /** |
| * Horizontal alignment of the dialog with respect to its origin. |
| * Center by default. |
| */ |
| horizontalAlignment: DialogHorizontalAlignment; |
| /** |
| * Optional function used to the determine the x coordinate of the connector's |
| * end (tip of the triangle), relative to the viewport. If not defined, the x |
| * coordinate of the origin's center is used instead. |
| */ |
| getConnectorCustomXPosition: (() => number)|null; |
| |
| /** |
| * Optional function called when the dialog is shown. |
| */ |
| dialogShownCallback: (() => unknown)|null; |
| |
| /** |
| * Whether the dialog is closed when the 'Escape' key is pressed. When true, the event is |
| * propagation is stopped. |
| */ |
| closeOnESC: boolean; |
| /** |
| * Whether the dialog is closed when a scroll event is detected outside of the dialog's |
| * content. Defaults to true. |
| */ |
| closeOnScroll: boolean; |
| /** |
| * Whether render a closed button, when it is clicked, close the dialog. Defaults to false. |
| */ |
| closeButton: boolean; |
| /** |
| * The string used in the header row of the dialog. |
| */ |
| dialogTitle: string; |
| /** |
| * Specifies a context for the visual element. |
| */ |
| jslogContext: string; |
| /** |
| * By default the dialog will close if any mutations to the DOM outside of it |
| * are detected. By setting this selector, any mutations on elements that |
| * match the selector will not cause the dialog to close. |
| */ |
| expectedMutationsSelector?: string; |
| |
| /** |
| * The current state of the dialog (expanded or collapsed). |
| * Defaults to COLLAPSED. |
| */ |
| state?: DialogState; |
| } |
| |
| type DialogAnchor = HTMLElement|DOMRect|DOMPoint; |
| |
| export const MODAL = 'MODAL'; |
| |
| export type DialogOrigin = DialogAnchor|null|(() => DialogAnchor)|typeof MODAL; |
| export class Dialog extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| readonly #forceDialogCloseInDevToolsBound = this.#forceDialogCloseInDevToolsMutation.bind(this); |
| readonly #handleScrollAttemptBound = this.#handleScrollAttempt.bind(this); |
| readonly #props: DialogData = { |
| origin: MODAL, |
| position: DialogVerticalPosition.BOTTOM, |
| horizontalAlignment: DialogHorizontalAlignment.CENTER, |
| getConnectorCustomXPosition: null, |
| dialogShownCallback: null, |
| closeOnESC: true, |
| closeOnScroll: true, |
| closeButton: false, |
| dialogTitle: '', |
| jslogContext: '', |
| state: DialogState.EXPANDED, |
| }; |
| |
| #dialog: HTMLDialogElement|null = null; |
| #isPendingShowDialog = false; |
| #isPendingCloseDialog = false; |
| #hitArea = new DOMRect(0, 0, 0, 0); |
| #dialogClientRect = new DOMRect(0, 0, 0, 0); |
| #bestVerticalPosition: DialogVerticalPosition|null = null; |
| #bestHorizontalAlignment: DialogHorizontalAlignment|null = null; |
| readonly #devtoolsMutationObserver = new MutationObserver(mutations => { |
| if (this.#props.expectedMutationsSelector) { |
| const allExcluded = mutations.every(mutation => { |
| return mutation.target instanceof Element && |
| mutation.target.matches(this.#props.expectedMutationsSelector ?? ''); |
| }); |
| if (allExcluded) { |
| return; |
| } |
| } |
| this.#forceDialogCloseInDevToolsBound(); |
| }); |
| readonly #dialogResizeObserver = new ResizeObserver(this.#updateDialogBounds.bind(this)); |
| #devToolsBoundingElement = UI.UIUtils.getDevToolsBoundingElement(); |
| |
| // We bind here because we have to listen to keydowns on the entire window, |
| // not on the Dialog element itself. This is because if the user has the |
| // dialog open, but their focus is elsewhere, and they hit ESC, we should |
| // still close the dialog. |
| #onKeyDownBound = this.#onKeyDown.bind(this); |
| |
| get origin(): DialogOrigin { |
| return this.#props.origin; |
| } |
| |
| set origin(origin: DialogOrigin) { |
| this.#props.origin = origin; |
| this.#onStateChange(); |
| } |
| |
| set expectedMutationsSelector(mutationSelector: string) { |
| this.#props.expectedMutationsSelector = mutationSelector; |
| } |
| |
| get expectedMutationsSelector(): string|undefined { |
| return this.#props.expectedMutationsSelector; |
| } |
| |
| get position(): DialogVerticalPosition { |
| return this.#props.position; |
| } |
| |
| set position(position: DialogVerticalPosition) { |
| this.#props.position = position; |
| this.#onStateChange(); |
| } |
| |
| get horizontalAlignment(): DialogHorizontalAlignment { |
| return this.#props.horizontalAlignment; |
| } |
| |
| set horizontalAlignment(alignment: DialogHorizontalAlignment) { |
| this.#props.horizontalAlignment = alignment; |
| this.#onStateChange(); |
| } |
| |
| get bestVerticalPosition(): DialogVerticalPosition|null { |
| return this.#bestVerticalPosition; |
| } |
| |
| get bestHorizontalAlignment(): DialogHorizontalAlignment|null { |
| return this.#bestHorizontalAlignment; |
| } |
| get getConnectorCustomXPosition(): (() => number)|null { |
| return this.#props.getConnectorCustomXPosition; |
| } |
| |
| set getConnectorCustomXPosition(connectorXPosition: (() => number)|null) { |
| this.#props.getConnectorCustomXPosition = connectorXPosition; |
| this.#onStateChange(); |
| } |
| |
| get dialogShownCallback(): (() => unknown)|null { |
| return this.#props.dialogShownCallback; |
| } |
| |
| get jslogContext(): string { |
| return this.#props.jslogContext; |
| } |
| |
| set dialogShownCallback(dialogShownCallback: (() => unknown)|null) { |
| this.#props.dialogShownCallback = dialogShownCallback; |
| this.#onStateChange(); |
| } |
| |
| set closeOnESC(closeOnESC: boolean) { |
| this.#props.closeOnESC = closeOnESC; |
| this.#onStateChange(); |
| } |
| |
| set closeOnScroll(closeOnScroll: boolean) { |
| this.#props.closeOnScroll = closeOnScroll; |
| this.#onStateChange(); |
| } |
| |
| set closeButton(closeButton: boolean) { |
| this.#props.closeButton = closeButton; |
| this.#onStateChange(); |
| } |
| |
| set dialogTitle(dialogTitle: string) { |
| this.#props.dialogTitle = dialogTitle; |
| this.#onStateChange(); |
| } |
| |
| set jslogContext(jslogContext: string) { |
| this.#props.jslogContext = jslogContext; |
| this.#onStateChange(); |
| } |
| |
| set state(state: DialogState) { |
| this.#props.state = state; |
| |
| // Handles teardown process in case dialog is collapsed or disabled |
| if (this.#props.state === DialogState.COLLAPSED || this.#props.state === DialogState.DISABLED) { |
| this.#forceDialogCloseInDevToolsBound(); |
| } |
| |
| this.#onStateChange(); |
| } |
| |
| #updateDialogBounds(): void { |
| this.#dialogClientRect = this.#getDialog().getBoundingClientRect(); |
| } |
| |
| #onStateChange(): void { |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| connectedCallback(): void { |
| window.addEventListener('resize', this.#forceDialogCloseInDevToolsBound); |
| this.#devtoolsMutationObserver.observe(this.#devToolsBoundingElement, {childList: true, subtree: true}); |
| this.#devToolsBoundingElement.addEventListener('wheel', this.#handleScrollAttemptBound); |
| this.style.setProperty('--dialog-padding', '0'); |
| this.style.setProperty('--dialog-display', IS_DIALOG_SUPPORTED ? 'block' : 'none'); |
| this.style.setProperty('--override-dialog-content-border', `${CONNECTOR_HEIGHT}px solid transparent`); |
| this.style.setProperty('--dialog-padding', `${DIALOG_VERTICAL_PADDING}px ${DIALOG_SIDE_PADDING}px`); |
| } |
| |
| disconnectedCallback(): void { |
| window.removeEventListener('resize', this.#forceDialogCloseInDevToolsBound); |
| |
| this.#devToolsBoundingElement.removeEventListener('wheel', this.#handleScrollAttemptBound); |
| this.#devtoolsMutationObserver.disconnect(); |
| this.#dialogResizeObserver.disconnect(); |
| } |
| |
| #getDialog(): HTMLDialogElement { |
| if (!this.#dialog) { |
| this.#dialog = this.#shadow.querySelector('dialog'); |
| if (!this.#dialog) { |
| throw new Error('Dialog not found'); |
| } |
| this.#dialogResizeObserver.observe(this.#dialog); |
| } |
| return this.#dialog; |
| } |
| |
| getHitArea(): DOMRect { |
| return this.#hitArea; |
| } |
| |
| async setDialogVisible(show: boolean): Promise<void> { |
| if (show) { |
| await this.#showDialog(); |
| return; |
| } |
| |
| this.#closeDialog(); |
| } |
| |
| async #handlePointerEvent(evt: MouseEvent|PointerEvent): Promise<void> { |
| evt.stopPropagation(); |
| // If the user uses the keyboard to interact with an element within the |
| // dialog, it will trigger a pointer event (for example, the user might use |
| // their spacebar to "click" on a form input element). In that case the |
| // pointerType will be an empty string, rather than `mouse`, `pen` or |
| // `touch`. In this instance, we early return, because we only need to |
| // worry about clicks outside of the dialog. Once the dialog is open, the |
| // user can only use the keyboard to navigate within the dialog; so we |
| // don't have to concern ourselves with keyboard events that occur outside |
| // the dialog's bounds. |
| if (evt instanceof PointerEvent && evt.pointerType === '') { |
| return; |
| } |
| |
| const eventWasInDialogContent = this.#mouseEventWasInDialogContent(evt); |
| const eventWasInHitArea = this.#mouseEventWasInHitArea(evt); |
| if (eventWasInDialogContent) { |
| return; |
| } |
| if (evt.type === 'pointermove') { |
| if (eventWasInHitArea) { |
| return; |
| } |
| this.dispatchEvent(new PointerLeftDialogEvent()); |
| return; |
| } |
| this.dispatchEvent(new ClickOutsideDialogEvent()); |
| } |
| |
| #animationEndedEvent(): void { |
| this.dispatchEvent(new AnimationEndedEvent()); |
| } |
| |
| #mouseEventWasInDialogContent(evt: MouseEvent): boolean { |
| const dialogBounds = this.#dialogClientRect; |
| |
| let animationOffSetValue = this.bestVerticalPosition === DialogVerticalPosition.BOTTOM ? |
| DIALOG_ANIMATION_OFFSET : |
| -1 * DIALOG_ANIMATION_OFFSET; |
| if (this.#props.origin === MODAL) { |
| // When shown as a modal, the dialog is not animated |
| animationOffSetValue = 0; |
| } |
| const eventWasDialogContentX = |
| evt.pageX >= dialogBounds.left && evt.pageX <= dialogBounds.left + dialogBounds.width; |
| const eventWasDialogContentY = evt.pageY >= dialogBounds.top + animationOffSetValue && |
| evt.pageY <= dialogBounds.top + dialogBounds.height + animationOffSetValue; |
| |
| return eventWasDialogContentX && eventWasDialogContentY; |
| } |
| |
| #mouseEventWasInHitArea(evt: MouseEvent): boolean { |
| const hitAreaBounds = this.#hitArea; |
| const eventWasInHitAreaX = evt.pageX >= hitAreaBounds.left && evt.pageX <= hitAreaBounds.left + hitAreaBounds.width; |
| const eventWasInHitAreaY = evt.pageY >= hitAreaBounds.top && evt.pageY <= hitAreaBounds.top + hitAreaBounds.height; |
| |
| return eventWasInHitAreaX && eventWasInHitAreaY; |
| } |
| |
| #getCoordinatesFromDialogOrigin(origin: DialogOrigin): AnchorBounds { |
| if (!origin || origin === MODAL) { |
| throw new Error('Dialog origin is null'); |
| } |
| const anchor = origin instanceof Function ? origin() : origin; |
| if (anchor instanceof DOMPoint) { |
| return {top: anchor.y, bottom: anchor.y, left: anchor.x, right: anchor.x}; |
| } |
| if (anchor instanceof HTMLElement) { |
| return anchor.getBoundingClientRect(); |
| } |
| return anchor; |
| } |
| |
| #getBestHorizontalAlignment(anchorBounds: AnchorBounds, devtoolsBounds: DOMRect): DialogHorizontalAlignment { |
| if (devtoolsBounds.right - anchorBounds.left > anchorBounds.right - devtoolsBounds.left) { |
| return DialogHorizontalAlignment.LEFT; |
| } |
| return DialogHorizontalAlignment.RIGHT; |
| } |
| |
| #getBestVerticalPosition(originBounds: AnchorBounds, dialogHeight: number, devtoolsBounds: DOMRect): |
| DialogVerticalPosition { |
| // If the dialog's full height doesn't fit at the bottom attempt to |
| // position it at the top. If it doesn't fit at the top either |
| // position it at the bottom and make the overflow scrollable. |
| if (originBounds.bottom + dialogHeight > devtoolsBounds.height && |
| originBounds.top - dialogHeight > devtoolsBounds.top) { |
| return DialogVerticalPosition.TOP; |
| } |
| return DialogVerticalPosition.BOTTOM; |
| } |
| |
| #positionDialog(): void { |
| if (!this.#props.origin) { |
| return; |
| } |
| |
| this.#isPendingShowDialog = true; |
| void RenderCoordinator.read(() => { |
| // Fixed elements are positioned relative to the window, regardless if |
| // DevTools is docked. As such, if DevTools is docked we must account for |
| // its offset relative to the window when positioning fixed elements. |
| // DevTools' effective offset can be determined using |
| // this.#devToolsBoundingElement. |
| const devtoolsBounds = this.#devToolsBoundingElement.getBoundingClientRect(); |
| const devToolsWidth = devtoolsBounds.width; |
| const devToolsHeight = devtoolsBounds.height; |
| const devToolsLeft = devtoolsBounds.left; |
| const devToolsTop = devtoolsBounds.top; |
| const devToolsRight = devtoolsBounds.right; |
| if (this.#props.origin === MODAL) { |
| void RenderCoordinator.write(() => { |
| this.style.setProperty('--dialog-top', `${devToolsTop}px`); |
| this.style.setProperty('--dialog-left', `${devToolsLeft}px`); |
| this.style.setProperty('--dialog-margin', 'auto'); |
| this.style.setProperty('--dialog-margin-left', 'auto'); |
| this.style.setProperty('--dialog-margin-bottom', 'auto'); |
| this.style.setProperty('--dialog-max-height', `${devToolsHeight - DIALOG_PADDING_FROM_WINDOW}px`); |
| this.style.setProperty('--dialog-max-width', `${devToolsWidth - DIALOG_PADDING_FROM_WINDOW}px`); |
| this.style.setProperty('--dialog-right', `${document.body.clientWidth - devToolsRight}px`); |
| }); |
| return; |
| } |
| const anchor = this.#props.origin; |
| const absoluteAnchorBounds = this.#getCoordinatesFromDialogOrigin(anchor); |
| const {top: anchorTop, right: anchorRight, bottom: anchorBottom, left: anchorLeft} = absoluteAnchorBounds; |
| const originCenterX = (anchorLeft + anchorRight) / 2; |
| const hitAreaWidth = anchorRight - anchorLeft + CONNECTOR_HEIGHT; |
| const windowWidth = document.body.clientWidth; |
| const connectorFixedXValue = |
| this.#props.getConnectorCustomXPosition ? this.#props.getConnectorCustomXPosition() : originCenterX; |
| void RenderCoordinator.write(() => { |
| this.style.setProperty('--dialog-top', '0'); |
| |
| // Start by showing the dialog hidden to allow measuring its width. |
| const dialog = this.#getDialog(); |
| dialog.style.visibility = 'hidden'; |
| if (this.#isPendingShowDialog && !dialog.hasAttribute('open')) { |
| if (!dialog.isConnected) { |
| return; |
| } |
| dialog.showModal(); |
| this.setAttribute('open', ''); |
| this.#isPendingShowDialog = false; |
| } |
| const {width: dialogWidth, height: dialogHeight} = dialog.getBoundingClientRect(); |
| this.#bestHorizontalAlignment = this.#props.horizontalAlignment === DialogHorizontalAlignment.AUTO ? |
| this.#getBestHorizontalAlignment(absoluteAnchorBounds, devtoolsBounds) : |
| this.#props.horizontalAlignment; |
| |
| this.#bestVerticalPosition = this.#props.position === DialogVerticalPosition.AUTO ? |
| this.#getBestVerticalPosition(absoluteAnchorBounds, dialogHeight, devtoolsBounds) : |
| this.#props.position; |
| if (this.#bestHorizontalAlignment === DialogHorizontalAlignment.AUTO || |
| this.#bestVerticalPosition === DialogVerticalPosition.AUTO) { |
| return; |
| } |
| this.#hitArea.height = anchorBottom - anchorTop + CONNECTOR_HEIGHT; |
| this.#hitArea.width = hitAreaWidth; |
| // If the connector is to be shown, the dialog needs a minimum width such that it covers |
| // the connector's width. |
| this.style.setProperty( |
| '--content-min-width', |
| `${connectorFixedXValue - anchorLeft + CONNECTOR_WIDTH + DIALOG_SIDE_PADDING * 2}px`); |
| this.style.setProperty('--dialog-left', 'auto'); |
| this.style.setProperty('--dialog-right', 'auto'); |
| this.style.setProperty('--dialog-margin', '0'); |
| switch (this.#bestHorizontalAlignment) { |
| case DialogHorizontalAlignment.LEFT: { |
| // Position the dialog such that its left border is in line with that of its anchor. |
| // If this means the dialog's left border is out of DevTools bounds, move it to the right. |
| // Cap its width as needed so that the right border doesn't overflow. |
| const dialogLeft = Math.max(anchorLeft, devToolsLeft); |
| const devtoolsRightBorderToDialogLeft = devToolsRight - dialogLeft; |
| const dialogMaxWidth = devtoolsRightBorderToDialogLeft - DIALOG_PADDING_FROM_WINDOW; |
| this.style.setProperty('--dialog-left', `${dialogLeft}px`); |
| this.#hitArea.x = anchorLeft; |
| this.style.setProperty('--dialog-max-width', `${dialogMaxWidth}px`); |
| break; |
| } |
| case DialogHorizontalAlignment.RIGHT: { |
| // Position the dialog such that its right border is in line with that of its anchor. |
| // If this means the dialog's right border is out of DevTools bounds, move it to the left. |
| // Cap its width as needed so that the left border doesn't overflow. |
| const windowRightBorderToAnchorRight = windowWidth - anchorRight; |
| const windowRightBorderToDevToolsRight = windowWidth - devToolsRight; |
| const windowRightBorderToDialogRight = |
| Math.max(windowRightBorderToAnchorRight, windowRightBorderToDevToolsRight); |
| |
| const dialogRight = windowWidth - windowRightBorderToDialogRight; |
| const devtoolsLeftBorderToDialogRight = dialogRight - devToolsLeft; |
| const dialogMaxWidth = devtoolsLeftBorderToDialogRight - DIALOG_PADDING_FROM_WINDOW; |
| |
| this.#hitArea.x = windowWidth - windowRightBorderToDialogRight - hitAreaWidth; |
| this.style.setProperty('--dialog-right', `${windowRightBorderToDialogRight}px`); |
| this.style.setProperty('--dialog-max-width', `${dialogMaxWidth}px`); |
| break; |
| } |
| case DialogHorizontalAlignment.CENTER: { |
| // Position the dialog aligned with its anchor's center as long as its borders don't overlap |
| // with those of DevTools. In case one border overlaps, move the dialog to the opposite side. |
| // In case both borders overlap, reduce its width to that of DevTools. |
| const dialogCappedWidth = Math.min(devToolsWidth - DIALOG_PADDING_FROM_WINDOW, dialogWidth); |
| |
| let dialogLeft = Math.max(originCenterX - dialogCappedWidth * 0.5, devToolsLeft); |
| dialogLeft = Math.min(dialogLeft, devToolsRight - dialogCappedWidth); |
| this.style.setProperty('--dialog-left', `${dialogLeft}px`); |
| this.#hitArea.x = originCenterX - hitAreaWidth * 0.5; |
| this.style.setProperty('--dialog-max-width', `${devToolsWidth - DIALOG_PADDING_FROM_WINDOW}px`); |
| break; |
| } |
| default: |
| Platform.assertNever( |
| this.#bestHorizontalAlignment, `Unknown alignment type: ${this.#bestHorizontalAlignment}`); |
| } |
| |
| switch (this.#bestVerticalPosition) { |
| case DialogVerticalPosition.TOP: { |
| this.style.setProperty('--dialog-top', '0'); |
| this.style.setProperty('--dialog-margin', 'auto'); |
| this.style.setProperty('--dialog-margin-bottom', `${innerHeight - anchorTop}px`); |
| this.#hitArea.y = anchorTop - CONNECTOR_HEIGHT; |
| this.style.setProperty('--dialog-offset-y', `${DIALOG_ANIMATION_OFFSET}px`); |
| this.style.setProperty( |
| '--dialog-max-height', `${devToolsHeight - (innerHeight - anchorTop) - DIALOG_PADDING_FROM_WINDOW}px`); |
| break; |
| } |
| case DialogVerticalPosition.BOTTOM: { |
| this.style.setProperty('--dialog-top', `${anchorBottom}px`); |
| this.#hitArea.y = anchorTop; |
| this.style.setProperty('--dialog-offset-y', `-${DIALOG_ANIMATION_OFFSET}px`); |
| this.style.setProperty( |
| '--dialog-max-height', |
| `${devToolsHeight - (anchorBottom - devToolsTop) - DIALOG_PADDING_FROM_WINDOW}px`); |
| break; |
| } |
| default: |
| Platform.assertNever(this.#bestVerticalPosition, `Unknown position type: ${this.#bestVerticalPosition}`); |
| } |
| |
| dialog.close(); |
| dialog.style.visibility = ''; |
| }); |
| }); |
| } |
| |
| async #showDialog(): Promise<void> { |
| if (!IS_DIALOG_SUPPORTED) { |
| return; |
| } |
| |
| if (this.#isPendingShowDialog || this.hasAttribute('open')) { |
| return; |
| } |
| this.#isPendingShowDialog = true; |
| this.#positionDialog(); |
| // Allow the CSS variables to be set before showing. |
| await RenderCoordinator.done(); |
| this.#isPendingShowDialog = false; |
| const dialog = this.#getDialog(); |
| if (!dialog.isConnected) { |
| return; |
| } |
| // Make the dialog visible now. |
| if (!dialog.hasAttribute('open')) { |
| dialog.showModal(); |
| } |
| if (this.#props.dialogShownCallback) { |
| await this.#props.dialogShownCallback(); |
| } |
| this.#updateDialogBounds(); |
| document.body.addEventListener('keydown', this.#onKeyDownBound); |
| } |
| |
| #handleScrollAttempt(event: WheelEvent): void { |
| if (this.#mouseEventWasInDialogContent(event) || !this.#props.closeOnScroll || |
| !this.#getDialog().hasAttribute('open')) { |
| return; |
| } |
| this.#closeDialog(); |
| this.dispatchEvent(new ForcedDialogClose()); |
| } |
| |
| #onKeyDown(event: KeyboardEvent): void { |
| if (!this.#getDialog().hasAttribute('open') || !this.#props.closeOnESC) { |
| return; |
| } |
| |
| if (event.key !== Platform.KeyboardUtilities.ESCAPE_KEY) { |
| return; |
| } |
| event.stopPropagation(); |
| event.preventDefault(); |
| this.#closeDialog(); |
| this.dispatchEvent(new ForcedDialogClose()); |
| } |
| |
| #onCancel(event: Event): void { |
| event.stopPropagation(); |
| event.preventDefault(); |
| if (!this.#getDialog().hasAttribute('open') || !this.#props.closeOnESC) { |
| return; |
| } |
| this.dispatchEvent(new ForcedDialogClose()); |
| } |
| |
| #forceDialogCloseInDevToolsMutation(): void { |
| if (!this.#dialog?.hasAttribute('open')) { |
| return; |
| } |
| if (this.#devToolsBoundingElement === document.body) { |
| // Do not close if running in test environment. |
| return; |
| } |
| this.#closeDialog(); |
| this.dispatchEvent(new ForcedDialogClose()); |
| } |
| |
| #closeDialog(): void { |
| if (this.#isPendingCloseDialog || !this.#getDialog().hasAttribute('open')) { |
| return; |
| } |
| this.#isPendingCloseDialog = true; |
| void RenderCoordinator.write(() => { |
| this.#hitArea.width = 0; |
| this.removeAttribute('open'); |
| this.#getDialog().close(); |
| this.#isPendingCloseDialog = false; |
| document.body.removeEventListener('keydown', this.#onKeyDownBound); |
| }); |
| } |
| |
| getDialogBounds(): DOMRect { |
| return this.#dialogClientRect; |
| } |
| |
| #renderHeaderRow(): Lit.TemplateResult|null { |
| // If the title is empty and close button is false, let's skip the header row. |
| if (!this.#props.dialogTitle && !this.#props.closeButton) { |
| return null; |
| } |
| // Disabled until https://crbug.com/1079231 is fixed. |
| // clang-format off |
| return html` |
| <span class="dialog-header-text">${this.#props.dialogTitle}</span> |
| ${this.#props.closeButton ? html` |
| <devtools-button |
| @click=${this.#closeDialog} |
| .data=${{ |
| variant: Buttons.Button.Variant.TOOLBAR, |
| iconName: 'cross', |
| title: i18nString(UIStrings.close), |
| size: Buttons.Button.Size.SMALL, |
| } as Buttons.Button.ButtonData} |
| jslog=${VisualLogging.close().track({click: true})} |
| ></devtools-button> |
| ` : Lit.nothing} |
| `; |
| // clang-format on |
| } |
| |
| #render(): void { |
| if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { |
| throw new Error('Dialog render was not scheduled'); |
| } |
| |
| if (!IS_DIALOG_SUPPORTED) { |
| // To make sure that light dom content passed into this component doesn't show up, |
| // we have to explicitly render a slot and hide it with CSS. |
| Lit.render( |
| // clang-format off |
| html` |
| <slot></slot> |
| `, this.#shadow, {host: this}); |
| // clang-format on |
| return; |
| } |
| |
| let dialogContent: Lit.LitTemplate = Lit.nothing; |
| |
| // If state is expanded content should be shown, do not render it otherwise. |
| if (this.#props.state === DialogState.EXPANDED) { |
| dialogContent = html` |
| <div id="content"> |
| <div class="dialog-header">${this.#renderHeaderRow()}</div> |
| <div class='dialog-content'> |
| <slot></slot> |
| </div> |
| </div> |
| `; |
| } |
| |
| // clang-format off |
| Lit.render(html` |
| <style>${dialogStyles}</style> |
| <dialog @click=${this.#handlePointerEvent} @pointermove=${this.#handlePointerEvent} @cancel=${this.#onCancel} @animationend=${this.#animationEndedEvent} |
| jslog=${VisualLogging.dialog(this.#props.jslogContext).track({ resize: true, keydown: 'Escape' }).parent('mapped')}> |
| ${dialogContent} |
| </dialog> |
| `, this.#shadow, { host: this }); |
| VisualLogging.setMappedParent(this.#getDialog(), this.parentElementOrShadowHost() as HTMLElement); |
| // clang-format on |
| } |
| |
| setBoundingElementForTesting(element: HTMLElement): void { |
| this.#devToolsBoundingElement = element; |
| this.#onStateChange(); |
| } |
| } |
| |
| customElements.define('devtools-dialog', Dialog); |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-dialog': Dialog; |
| } |
| } |
| |
| export class PointerLeftDialogEvent extends Event { |
| static readonly eventName = 'pointerleftdialog'; |
| |
| constructor() { |
| super(PointerLeftDialogEvent.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| |
| export class ClickOutsideDialogEvent extends Event { |
| static readonly eventName = 'clickoutsidedialog'; |
| |
| constructor() { |
| super(ClickOutsideDialogEvent.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| |
| export class AnimationEndedEvent extends Event { |
| static readonly eventName = 'animationended'; |
| |
| constructor() { |
| super(AnimationEndedEvent.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| |
| export class ForcedDialogClose extends Event { |
| static readonly eventName = 'forceddialogclose'; |
| constructor() { |
| super(ForcedDialogClose.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| |
| export const enum DialogVerticalPosition { |
| TOP = 'top', |
| BOTTOM = 'bottom', |
| AUTO = 'auto', |
| } |
| |
| export const enum DialogState { |
| EXPANDED = 'expanded', |
| COLLAPSED = 'collapsed', |
| DISABLED = 'disabled' |
| } |
| |
| export const enum DialogHorizontalAlignment { |
| // Dialog and anchor are aligned on their left borders. |
| LEFT = 'left', |
| // Dialog and anchor are aligned on their right borders. |
| RIGHT = 'right', |
| CENTER = 'center', |
| // This option allows to set the alignment |
| // automatically to LEFT or RIGHT depending |
| // on whether the dialog overflows the |
| // viewport if it's aligned to the left. |
| AUTO = 'auto', |
| } |
| |
| interface AnchorBounds { |
| top: number; |
| bottom: number; |
| left: number; |
| right: number; |
| } |