From 21748de83a382b76da6b33cc569d02c89c73e6b7 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Thu, 9 Nov 2023 12:44:44 +0000 Subject: [PATCH] refactor Dialog to use internally --- app/components/primer/alpha/dialog.html.erb | 4 +- app/components/primer/alpha/dialog.rb | 3 +- app/components/primer/alpha/modal_dialog.ts | 199 -------------------- app/components/primer/alpha/overlay.pcss | 7 +- app/components/primer/dialog_helpers.ts | 77 ++++++++ app/components/primer/primer.ts | 2 +- 6 files changed, 87 insertions(+), 205 deletions(-) delete mode 100644 app/components/primer/alpha/modal_dialog.ts create mode 100644 app/components/primer/dialog_helpers.ts diff --git a/app/components/primer/alpha/dialog.html.erb b/app/components/primer/alpha/dialog.html.erb index 8ebded10ce..67414761c6 100644 --- a/app/components/primer/alpha/dialog.html.erb +++ b/app/components/primer/alpha/dialog.html.erb @@ -1,5 +1,5 @@ <%= show_button %> -
+ <%= render Primer::BaseComponent.new(**@system_arguments) do %> <%= header %> <% if content.present? %> @@ -9,4 +9,4 @@ <%= footer %> <% end %> <% end %> -
+ diff --git a/app/components/primer/alpha/dialog.rb b/app/components/primer/alpha/dialog.rb index e5efdc842f..bae650b7a9 100644 --- a/app/components/primer/alpha/dialog.rb +++ b/app/components/primer/alpha/dialog.rb @@ -125,8 +125,7 @@ def initialize( @position_narrow = position_narrow @visually_hide_title = visually_hide_title - @system_arguments[:tag] = "modal-dialog" - @system_arguments[:role] = "dialog" + @system_arguments[:tag] = "dialog" @system_arguments[:id] = @id @system_arguments[:aria] = { modal: true } @system_arguments[:aria] = merge_aria( diff --git a/app/components/primer/alpha/modal_dialog.ts b/app/components/primer/alpha/modal_dialog.ts deleted file mode 100644 index 840becf0ec..0000000000 --- a/app/components/primer/alpha/modal_dialog.ts +++ /dev/null @@ -1,199 +0,0 @@ -import {focusTrap} from '@primer/behaviors' -import {getFocusableChild} from '@primer/behaviors/utils' - -function focusIfNeeded(elem: HTMLElement | undefined | null) { - if (document.activeElement !== elem) { - elem?.focus() - } -} - -const overlayStack: ModalDialogElement[] = [] - -function clickHandler(event: Event) { - const target = event.target as HTMLElement - const button = target?.closest('button') - - if (!button || button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true') return - - // If the user is clicking a valid dialog trigger - let dialogId = button?.getAttribute('data-show-dialog-id') - if (dialogId) { - event.stopPropagation() - const dialog = document.getElementById(dialogId) - if (dialog instanceof ModalDialogElement) { - dialog.openButton = button - dialog.show() - // A buttons default behaviour in some browsers it to send a pointer event - // If the behaviour is allowed through the dialog will be shown but then - // quickly hidden- as if it were never shown. This prevents that. - event.preventDefault() - return - } - } - - if (!overlayStack.length) return - - dialogId = button.getAttribute('data-close-dialog-id') || button.getAttribute('data-submit-dialog-id') - if (dialogId) { - const dialog = document.getElementById(dialogId) - if (dialog instanceof ModalDialogElement) { - const dialogIndex = overlayStack.findIndex(ele => ele.id === dialogId) - overlayStack.splice(dialogIndex, 1) - dialog.close(button.hasAttribute('data-submit-dialog-id')) - } - } -} - -function keydownHandler(event: Event) { - if ( - !(event instanceof KeyboardEvent) || - event.type !== 'keydown' || - event.key !== 'Enter' || - event.ctrlKey || - event.altKey || - event.metaKey || - event.shiftKey - ) - return - - clickHandler(event) -} - -function mousedownHandler(event: Event) { - const target = event.target as HTMLElement - if (target?.closest('button')) return - - // Find the top level dialog that is open. - const topLevelDialog = overlayStack[overlayStack.length - 1] - if (!topLevelDialog) return - - // Check if the mousedown happened outside the boundary of the top level dialog - const mouseDownOutsideDialog = !target.closest(`#${topLevelDialog.getAttribute('id')}`) - - // Only close dialog if it's a click outside the dialog and the dialog has a button? - if (mouseDownOutsideDialog) { - target.ownerDocument.addEventListener( - 'mouseup', - (upEvent: Event) => { - if (upEvent.target === target) { - overlayStack.pop() - topLevelDialog.close() - } - }, - {once: true} - ) - } -} - -export class ModalDialogElement extends HTMLElement { - //TODO: Do we remove the abortController from focusTrap? - #focusAbortController = new AbortController() - openButton: HTMLButtonElement | null - - get open() { - return this.hasAttribute('open') - } - set open(value: boolean) { - if (value) { - if (this.open) return - this.setAttribute('open', '') - this.setAttribute('aria-disabled', 'false') - document.body.style.paddingRight = `${window.innerWidth - document.body.clientWidth}px` - document.body.style.overflow = 'hidden' - this.#overlayBackdrop?.classList.remove('Overlay--hidden') - if (this.#focusAbortController.signal.aborted) { - this.#focusAbortController = new AbortController() - } - focusTrap(this, this.querySelector('[autofocus]') as HTMLElement, this.#focusAbortController.signal) - overlayStack.push(this) - } else { - if (!this.open) return - this.removeAttribute('open') - this.setAttribute('aria-disabled', 'true') - this.#overlayBackdrop?.classList.add('Overlay--hidden') - document.body.style.paddingRight = '0' - document.body.style.overflow = 'initial' - this.#focusAbortController.abort() - // if #openButton is a child of a menu, we need to focus a suitable child of the menu - // element since it is expected for the menu to close on click - const menu = this.openButton?.closest('details') || this.openButton?.closest('action-menu') - if (menu) { - focusIfNeeded(getFocusableChild(menu)) - } else { - focusIfNeeded(this.openButton) - } - this.openButton = null - } - } - - get #overlayBackdrop(): HTMLElement | null { - if (this.parentElement?.hasAttribute('data-modal-dialog-overlay')) { - return this.parentElement - } - - return null - } - - get showButtons(): NodeList { - // Dialogs may also be opened from any arbitrary button with a matching show-dialog-id data attribute - return document.querySelectorAll(`button[data-show-dialog-id='${this.id}']`) - } - - connectedCallback(): void { - if (!this.hasAttribute('role')) this.setAttribute('role', 'dialog') - - document.addEventListener('click', clickHandler) - document.addEventListener('keydown', keydownHandler) - document.addEventListener('mousedown', mousedownHandler) - - this.addEventListener('keydown', e => this.#keydown(e)) - } - - show() { - this.open = true - } - - close(closedNotCancelled = false) { - if (this.open === false) return - const eventType = closedNotCancelled ? 'close' : 'cancel' - const dialogEvent = new Event(eventType) - this.dispatchEvent(dialogEvent) - this.open = false - } - - #keydown(event: Event) { - if (!(event instanceof KeyboardEvent)) return - if (event.isComposing) return - if (!this.open) return - - switch (event.key) { - case 'Escape': - this.close() - event.preventDefault() - event.stopPropagation() - break - case 'Enter': { - const target = event.target as HTMLElement - - if (target.getAttribute('data-close-dialog-id') === this.id) { - event.stopPropagation() - } - break - } - } - } -} - -declare global { - interface Window { - ModalDialogElement: typeof ModalDialogElement - } - interface HTMLElementTagNameMap { - 'modal-dialog': ModalDialogElement - } -} - -if (!window.customElements.get('modal-dialog')) { - window.ModalDialogElement = ModalDialogElement - window.customElements.define('modal-dialog', ModalDialogElement) -} diff --git a/app/components/primer/alpha/overlay.pcss b/app/components/primer/alpha/overlay.pcss index ec3afead27..d5be828621 100644 --- a/app/components/primer/alpha/overlay.pcss +++ b/app/components/primer/alpha/overlay.pcss @@ -9,8 +9,13 @@ anchored-position[popover] { .Overlay { display: flex; + border-width: 0; } -anchored-position.not-anchored::backdrop { +anchored-position.not-anchored::backdrop, dialog::backdrop { background-color: var(--overlay-backdrop-bgColor, var(--color-neutral-muted)); } + +dialog.Overlay:not([open]) { + display: none; +} diff --git a/app/components/primer/dialog_helpers.ts b/app/components/primer/dialog_helpers.ts new file mode 100644 index 0000000000..463a1063c5 --- /dev/null +++ b/app/components/primer/dialog_helpers.ts @@ -0,0 +1,77 @@ +function dialogInvokerButtonHandler(event: Event) { + const target = event.target as HTMLElement + const button = target?.closest('button') + + if (!button || button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true') return + + // If the user is clicking a valid dialog trigger + let dialogId = button?.getAttribute('data-show-dialog-id') + if (dialogId) { + event.stopPropagation() + const dialog = document.getElementById(dialogId) + if (dialog instanceof HTMLDialogElement) { + dialog.showModal() + // A buttons default behaviour in some browsers it to send a pointer event + // If the behaviour is allowed through the dialog will be shown but then + // quickly hidden- as if it were never shown. This prevents that. + event.preventDefault() + } + } + + dialogId = button.getAttribute('data-close-dialog-id') || button.getAttribute('data-submit-dialog-id') + if (dialogId) { + const dialog = document.getElementById(dialogId) + if (dialog instanceof HTMLDialogElement && dialog.open) { + dialog.close() + } + } +} + +export class DialogHelperElement extends HTMLElement { + get dialog() { + return this.querySelector('dialog') + } + + #abortController: AbortController | null = null + connectedCallback() { + const {signal} = (this.#abortController = new AbortController()) + document.addEventListener('click', dialogInvokerButtonHandler) + document.addEventListener('click', this) + } + + disconnectedCallback() { + this.#abortController?.abort() + } + + handleEvent(event) { + const target = event.target as HTMLElement + const dialog = this.dialog + if (!dialog.open) return + if (target?.closest('dialog') !== dialog) return + + const rect = dialog.getBoundingClientRect() + const clickWasInsideDialog = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width + + if (!clickWasInsideDialog) { + dialog.close() + } + } +} + +declare global { + interface Window { + DialogHelperElement: typeof DialogHelperElement + } + interface HTMLElementTagNameMap { + 'dialog-helper': DialogHelperElement + } +} + +if (!window.customElements.get('dialog-helper')) { + window.ModalDialogElement = DialogHelperElement + window.customElements.define('dialog-helper', DialogHelperElement) +} diff --git a/app/components/primer/primer.ts b/app/components/primer/primer.ts index c234aac2d9..e6a9d4cda2 100644 --- a/app/components/primer/primer.ts +++ b/app/components/primer/primer.ts @@ -2,9 +2,9 @@ import '@github/include-fragment-element' import './alpha/action_bar_element' import './alpha/dropdown' import './anchored_position' +import './dialog_helpers' import './focus_group' import './alpha/image_crop' -import './alpha/modal_dialog' import './beta/nav_list' import './alpha/segmented_control' import './alpha/toggle_switch'