diff --git a/api/Project.js b/api/Project.js index a7c9738a..8a0ec6a8 100644 --- a/api/Project.js +++ b/api/Project.js @@ -258,7 +258,7 @@ export default class Project { return response } catch (error) { console.error("Error updating roles:", error) - alert("Failed to update roles. Please try again.") + eventDispatcher.dispatch('tpen-alert', { message: "Failed to update roles. Please try again." }) } } diff --git a/components/annotorious-annotator/line-parser.js b/components/annotorious-annotator/line-parser.js index 79d39bf0..1df9e3a1 100644 --- a/components/annotorious-annotator/line-parser.js +++ b/components/annotorious-annotator/line-parser.js @@ -20,6 +20,7 @@ import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' import { onProjectReady } from '../../utilities/projectReady.js' import vault from '../../js/vault.js' import '../page-selector/index.js' +import { confirmAction } from '../../utilities/confirmAction.js' class AnnotoriousAnnotator extends HTMLElement { #osd @@ -466,7 +467,7 @@ class AnnotoriousAnnotator extends HTMLElement { }, 500) this.#pendingTimeouts.add(timeoutId) }) - this.renderCleanup.onElement(deleteAllBtn, "click", async (e) => await this.deleteAllAnnotations(e)) + this.renderCleanup.onElement(deleteAllBtn, "click", (e) => this.deleteAllAnnotations(e)) this.renderCleanup.onWindow('beforeunload', (ev) => { if (this.#resolvedAnnotationPage?.$isDirty) { ev.preventDefault() @@ -715,8 +716,14 @@ class AnnotoriousAnnotator extends HTMLElement { srcDown: "../interfaces/annotator/images/transcribe.png", onClick: (e) => { if (this.#resolvedAnnotationPage?.$isDirty) { - if (confirm("Stop identifying lines and go transcribe? Unsaved changes will be lost.")) - location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}` + confirmAction( + "Stop identifying lines and go transcribe? Unsaved changes will be lost.", + () => { + location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}` + }, + null, + { positiveButtonText: "Go Transcribe", negativeButtonText: "Keep Editing" } + ) } else { location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}` @@ -808,13 +815,15 @@ class AnnotoriousAnnotator extends HTMLElement { _this.#pendingTimeouts.delete(timeoutId) // Timeout required in order to allow the click-and-focus native functionality to complete. // Also stops the goofy UX for naturally slow clickers. - let c = confirm("Are you sure you want to remove this?") - if (c) { - _this.#annotoriousInstance.removeAnnotation(originalAnnotation) - _this.#resolvedAnnotationPage.$isDirty = true - } else { - _this.#annotoriousInstance.cancelSelected() - } + confirmAction( + "Are you sure you want to remove this?", + () => { + _this.#annotoriousInstance.removeAnnotation(originalAnnotation) + _this.#resolvedAnnotationPage.$isDirty = true + }, + () => _this.#annotoriousInstance.cancelSelected(), + { positiveButtonText: "Delete", negativeButtonText: "Cancel" } + ) }, 500) _this.#pendingTimeouts.add(timeoutId) } @@ -1139,30 +1148,37 @@ class AnnotoriousAnnotator extends HTMLElement { return this.#modifiedAnnotationPage } + /** * Use Annotorious to delete all known Annotations * https://annotorious.dev/api-reference/openseadragon-annotator/#clearannotations */ - async deleteAllAnnotations() { - if (!confirm('This will remove all Annotations and will take effect immediately. This action cannot be undone.')) return - const deleteAllBtn = this.shadowRoot.getElementById("deleteAllBtn") - deleteAllBtn.setAttribute("disabled", "true") - deleteAllBtn.textContent = "deleting. please wait..." - this.#annotoriousInstance.clearAnnotations() - this.#resolvedAnnotationPage.$isDirty = true - try { - await this.saveAnnotations() - await this.clearColumnsServerSide() - } catch (err) { - console.error("Could not delete all annotations.", err) - TPEN.eventDispatcher.dispatch("tpen-toast", { - message: "Could not delete all annotations.", - status: "error" - }) - } finally { - deleteAllBtn.removeAttribute("disabled") - deleteAllBtn.textContent = "Delete All Annotations" - } + deleteAllAnnotations() { + confirmAction( + "This will remove all Annotations and will take effect immediately. This action cannot be undone.", + () => { + const deleteAllBtn = this.shadowRoot.getElementById("deleteAllBtn") + deleteAllBtn.setAttribute("disabled", "true") + deleteAllBtn.textContent = "deleting. please wait..." + this.#annotoriousInstance.clearAnnotations() + this.#resolvedAnnotationPage.$isDirty = true + this.saveAnnotations() + .then(() => this.clearColumnsServerSide()) + .catch(err => { + console.error("Could not delete all annotations.", err) + TPEN.eventDispatcher.dispatch("tpen-toast", { + message: "Could not delete all annotations.", + status: "error" + }) + }) + .finally(() => { + deleteAllBtn.removeAttribute("disabled") + deleteAllBtn.textContent = "Delete All Annotations" + }) + }, + null, + { positiveButtonText: "Delete All", negativeButtonText: "Cancel" } + ) } /** diff --git a/components/annotorious-annotator/plain.js b/components/annotorious-annotator/plain.js index b73b95f8..ebf2bc35 100644 --- a/components/annotorious-annotator/plain.js +++ b/components/annotorious-annotator/plain.js @@ -14,6 +14,7 @@ import TPEN from '../../api/TPEN.js' import User from '../../api/User.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { confirmAction } from '../../utilities/confirmAction.js' class AnnotoriousAnnotator extends HTMLElement { #osd @@ -126,7 +127,7 @@ class AnnotoriousAnnotator extends HTMLElement { } this.#annotationPageURI = TPEN.screen.pageInQuery if(!this.#annotationPageURI) { - alert("You must provide a ?pageID=theid in the URL. The value should be the URI of an existing AnnotationPage.") + TPEN.eventDispatcher.dispatch('tpen-alert', { message: "You must provide a ?pageID=theid in the URL. The value should be the URI of an existing AnnotationPage." }) return } this.setAttribute("annotationpage", this.#annotationPageURI) @@ -257,13 +258,12 @@ class AnnotoriousAnnotator extends HTMLElement { _this.#eraseConfirmTimeout = null // Timeout required in order to allow the click-and-focus native functionality to complete. // Also stops the goofy UX for naturally slow clickers. - let c = confirm("Are you sure you want to remove this?") - if(c) { - _this.#annotoriousInstance.removeAnnotation(annotation) - } - else{ - _this.#annotoriousInstance.cancelSelected() - } + confirmAction( + "Are you sure you want to remove this?", + () => _this.#annotoriousInstance.removeAnnotation(annotation), + () => _this.#annotoriousInstance.cancelSelected(), + { positiveButtonText: "Delete", negativeButtonText: "Cancel" } + ) }, 500) } }) diff --git a/components/decline-project/index.js b/components/decline-project/index.js index 6596fa62..9ab96f03 100644 --- a/components/decline-project/index.js +++ b/components/decline-project/index.js @@ -1,5 +1,6 @@ import TPEN from "../../api/TPEN.js" import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { confirmAction } from '../../utilities/confirmAction.js' /** * DeclineInvite - Allows invited users to decline a project invitation. @@ -87,45 +88,51 @@ class DeclineInvite extends HTMLElement { } declineInvitation(collaboratorID, projectID) { - if (!confirm("You are declining a chance to be a part of this TPEN3 project.")) return - let redir = true - const declineBtn = this.shadowRoot.getElementById("declineBtn") - declineBtn.setAttribute("disabled", "disabled") - declineBtn.setAttribute("value", "declining...") - fetch(`${TPEN.servicesURL}/project/${this.#project}/collaborator/${this.#user}/decline`) - .then(resp => { - if (resp.ok) return resp.text() - redir = false - return resp.json() - }) - .then(message => { - let userMessage = (typeof message === "string") ? message : message?.message - if (redir) { - this.shadowRoot.innerHTML = ` -

${userMessage}

- ` - setTimeout(() => { - document.location.href = TPEN.TPEN3URL - }, 3000) - return - } - this.shadowRoot.innerHTML = ` -

There was an error declining the invitation.

-

- The message below has more details. - Refresh the page to try again or contact the TPEN3 Administrators. -

- ${userMessage} - ` - }) - .catch(err => { - this.shadowRoot.innerHTML = ` -

- There was an error declining the invitation. Refresh the page to try again - or contact the TPEN3 Administrators. -

- ` - }) + confirmAction( + "You are declining a chance to be a part of this TPEN3 project.", + () => { + let redir = true + const declineBtn = this.shadowRoot.getElementById("declineBtn") + declineBtn.setAttribute("disabled", "disabled") + declineBtn.setAttribute("value", "declining...") + fetch(`${TPEN.servicesURL}/project/${this.#project}/collaborator/${this.#user}/decline`) + .then(resp => { + if (resp.ok) return resp.text() + redir = false + return resp.json() + }) + .then(message => { + let userMessage = (typeof message === "string") ? message : message?.message + if (redir) { + this.shadowRoot.innerHTML = ` +

${userMessage}

+ ` + setTimeout(() => { + document.location.href = TPEN.TPEN3URL + }, 3000) + return + } + this.shadowRoot.innerHTML = ` +

There was an error declining the invitation.

+

+ The message below has more details. + Refresh the page to try again or contact the TPEN3 Administrators. +

+ ${userMessage} + ` + }) + .catch(err => { + this.shadowRoot.innerHTML = ` +

+ There was an error declining the invitation. Refresh the page to try again + or contact the TPEN3 Administrators. +

+ ` + }) + }, + null, + { positiveButtonText: "Decline", negativeButtonText: "Cancel" } + ) } } diff --git a/components/gui/alert/Alert.js b/components/gui/alert/Alert.js index 6724402d..1779de14 100644 --- a/components/gui/alert/Alert.js +++ b/components/gui/alert/Alert.js @@ -1,5 +1,6 @@ import { eventDispatcher } from '../../../api/events.js' import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js' +import { openModalHost } from '../../../utilities/modalHost.js' /** * Alert - A modal alert dialog that requires user acknowledgement. @@ -25,11 +26,11 @@ class Alert extends HTMLElement { * Have them appear with a dropdown effect. */ show() { - this.closest(".alert-area").style.display = "grid" + const alertArea = this.closest('.alert-area') + openModalHost(alertArea) const showTimer = setTimeout(() => { - this.closest(".alert-area").classList.add("show") + alertArea?.classList.add('show') this.classList.add('show') - document.querySelector("body").style.overflow = "hidden" }, 1) this.cleanup.add(() => clearTimeout(showTimer)) eventDispatcher.dispatch("tpen-alert-activated") @@ -41,8 +42,7 @@ class Alert extends HTMLElement { */ dismiss() { this.classList.remove('show') - this.closest(".alert-area")?.classList.remove("show") - document.querySelector("body").style.overflow = "auto" + this.closest('.alert-area')?.classList.remove('show') const removeTimer = setTimeout(() => { this.remove() }, 500) diff --git a/components/gui/alert/AlertContainer.js b/components/gui/alert/AlertContainer.js index c52dd788..acd07083 100644 --- a/components/gui/alert/AlertContainer.js +++ b/components/gui/alert/AlertContainer.js @@ -1,6 +1,8 @@ import './Alert.js' import { eventDispatcher } from '../../../api/events.js' import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js' +import { closeModalHostWhenEmpty } from '../../../utilities/modalHost.js' +import { sharedModalStyles } from '../modal.css.js' /** * AlertContainer - Global container for displaying alert dialogs. @@ -9,6 +11,8 @@ import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js' */ class AlertContainer extends HTMLElement { #screenLockingSection + #alertQueue = [] + #keydownHandler /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() @@ -29,6 +33,7 @@ class AlertContainer extends HTMLElement { /** * Add the alert dialogue with acknowledgement button. + * Alerts are queued - only one is shown at a time. * * @params message {String} A message to show in the alert. * @params buttonText {String} The textual label for the acknowledgement button. @@ -36,117 +41,119 @@ class AlertContainer extends HTMLElement { addAlert(message, buttonText) { if (!message || typeof message !== 'string') return if (!buttonText || typeof buttonText !== 'string') buttonText = 'OK' - const { matches: motionOK } = window.matchMedia('(prefers-reduced-motion: no-preference)') + const alertElem = document.createElement('tpen-alert') const okButton = document.createElement('button') const buttonContainer = document.createElement('div') - buttonContainer.classList.add("button-container") + buttonContainer.classList.add('button-container') okButton.textContent = buttonText alertElem.textContent = message - const handleOk = (e) => { - alertElem.dismiss() - } + + const handleOk = () => this.dismissCurrent() okButton.addEventListener('click', handleOk) buttonContainer.appendChild(okButton) alertElem.appendChild(buttonContainer) - this.#screenLockingSection.appendChild(alertElem) - alertElem.show() + + const alertEntry = { elem: alertElem, button: okButton } + this.#alertQueue.push(alertEntry) + + // If this is the first alert, show it immediately + if (this.#alertQueue.length === 1) { + this.#showCurrent() + } + } + + /** + * Add a pre-built custom alert element to the global alert host. + * @param {HTMLElement} alertElem - Custom alert element, typically tpen-alert. + */ + addCustomAlert(alertElem) { + if (!alertElem) return + + const alertEntry = { elem: alertElem, button: alertElem.querySelector('button') } + this.#alertQueue.push(alertEntry) + + if (this.#alertQueue.length === 1) { + this.#showCurrent() + } + } + + /** + * Display the current (first) alert in the queue and set focus. + */ + #showCurrent() { + if (this.#alertQueue.length === 0) return + + const current = this.#alertQueue[0] + this.#screenLockingSection.appendChild(current.elem) + current.elem.show?.() + + // Set focus to button + current.button?.focus?.() + + // Use native dialog cancel event for Escape key (cleaner than document keydown) + const cancelHandler = (e) => { + e.preventDefault() + this.dismissCurrent() + } + this.#screenLockingSection.addEventListener('cancel', cancelHandler) + + // Attach keyboard handler for Enter key + if (this.#keydownHandler) { + document.removeEventListener('keydown', this.#keydownHandler) + } + this.#keydownHandler = (e) => this.#handleKeydown(e) + document.addEventListener('keydown', this.#keydownHandler) + + // Store cancel handler so it can be removed on dismiss + current.cancelHandler = cancelHandler + } + + /** + * Handle keyboard events for alerts. + * - Enter: dismiss current alert + * (Escape is handled via native dialog cancel event) + */ + #handleKeydown(e) { + const current = this.#alertQueue[0] + if (!current) return + } + + /** + * Dismiss the current alert and show the next one in queue. + */ + dismissCurrent() { + if (this.#alertQueue.length === 0) return + + const current = this.#alertQueue.shift() + + // Remove the cancel event listener + if (current.cancelHandler) { + this.#screenLockingSection.removeEventListener('cancel', current.cancelHandler) + } + + current.elem.dismiss() + + // Remove keyboard handler + if (this.#keydownHandler) { + document.removeEventListener('keydown', this.#keydownHandler) + this.#keydownHandler = null + } + + // Show next alert in queue if available + if (this.#alertQueue.length > 0) { + setTimeout(() => this.#showCurrent(), 600) // Wait for dismiss animation + return + } + + closeModalHostWhenEmpty(this.#screenLockingSection, 'tpen-alert') } render() { const style = document.createElement('style') - // We copied the :root rules from /components/gui/site/index.css. Importing it was too much. - style.textContent = ` - :host { - --primary-color: hsl(186, 84%, 40%); - --primary-light: hsl(186, 84%, 60%); - --light-color : hsl(186, 84%, 90%); - --dark : #2d2d2d; - --white : hsl(0, 0%, 100%); - --gray : hsl(0, 0%, 60%); - --light-gray : hsl(0, 0%, 90%); - } - .alert-area { - position: fixed; - display: grid; - z-index: 16; - inset-block-start: 0; - inset-inline: 0; - justify-items: center; - justify-content: center; - height: 0vh; - background-color: rgba(0,0,0,0.7); - opacity: 0; - transition: all 0.5s ease-in-out; - } - .alert-area.show { - opacity: 1; - height: 100vh; - } - tpen-alert { - z-index: 16; - display: block; - position: relative; - background-color: #333; - color: #fff; - padding: 10px 20px; - border-radius: 5px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); - opacity: 0.0; - height: fit-content; - min-width: 25vw; - max-width: 35vw; - transition: all 0.3s ease-in-out; - font-size: 14pt; - } - tpen-alert a { - color: var(--primary-color); - text-decoration: underline; - } - .alert-area tpen-alert { - top: 0px; - right: 0px; - } - @media (prefers-reduced-motion) { - .alert-area tpen-alert { - opacity: 1.0; - height: fit-content; - top: 5vh; - } - } - .alert-area .button-container { - position: relative; - display: block; - text-align: right; - margin-top: 1vh; - } - .alert-area tpen-alert.show { - opacity: 1.0; - height: fit-content; - top: 5vh; - } - .alert-area button { - position: relative; - display: inline-block; - cursor: pointer; - border: none; - padding: 10px 20px; - background-color: var(--primary-color); - outline: var(--primary-light) 1px solid; - outline-offset: -3.5px; - color: var(--white); - border-radius: 5px; - transition: all 0.3s; - font-size: 12pt; - } - .alert-area button:hover { - background-color: var(--primary-light); - outline: var(--primary-color) 1px solid; - outline-offset: -1.5px; - } - ` + style.textContent = sharedModalStyles // This section will take over the screen and lock down screen interaction. It lives at the top of the viewport. - const screenLockingSection = document.createElement('section') + const screenLockingSection = document.createElement('dialog') screenLockingSection.classList.add('alert-area') this.shadowRoot.innerHTML = '' @@ -159,5 +166,5 @@ class AlertContainer extends HTMLElement { // Guard against duplicate registration when module is loaded via different URL paths if (!customElements.get('tpen-alert-container')) { customElements.define('tpen-alert-container', AlertContainer) - document?.body.after(new AlertContainer()) + document.body.appendChild(new AlertContainer()) } diff --git a/components/gui/confirm/Confirm.js b/components/gui/confirm/Confirm.js index 5bb7b58b..a5f1a984 100644 --- a/components/gui/confirm/Confirm.js +++ b/components/gui/confirm/Confirm.js @@ -1,5 +1,6 @@ import { eventDispatcher } from '../../../api/events.js' import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js' +import { openModalHost } from '../../../utilities/modalHost.js' /** * Confirm - A modal confirmation dialog with positive/negative options. @@ -14,7 +15,7 @@ class Confirm extends HTMLElement { super() this.attachShadow({ mode: 'open' }) this.shadowRoot.innerHTML = ` - + ` @@ -25,11 +26,11 @@ class Confirm extends HTMLElement { * Have them appear with a dropdown effect. */ show() { - this.closest(".confirm-area").style.display = "grid" + const confirmArea = this.closest('.confirm-area') + openModalHost(confirmArea) const showTimer = setTimeout(() => { - this.closest(".confirm-area").classList.add("show") + confirmArea?.classList.add('show') this.classList.add('show') - document.querySelector("body").style.overflow = "hidden" }, 1) this.cleanup.add(() => clearTimeout(showTimer)) eventDispatcher.dispatch("tpen-confirm-activated") @@ -41,8 +42,7 @@ class Confirm extends HTMLElement { */ dismiss() { this.classList.remove('show') - this.closest(".confirm-area")?.classList.remove("show") - document.querySelector("body").style.overflow = "auto" + this.closest('.confirm-area')?.classList.remove('show') const removeTimer = setTimeout(() => { this.remove() }, 500) diff --git a/components/gui/confirm/ConfirmContainer.js b/components/gui/confirm/ConfirmContainer.js index 5f17b809..6d9bc21f 100644 --- a/components/gui/confirm/ConfirmContainer.js +++ b/components/gui/confirm/ConfirmContainer.js @@ -1,15 +1,20 @@ import './Confirm.js' import { eventDispatcher } from '../../../api/events.js' import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js' +import { closeModalHostWhenEmpty } from '../../../utilities/modalHost.js' +import { sharedModalStyles } from '../modal.css.js' /** * ConfirmContainer - Global container for displaying confirmation dialogs. * Listens for 'tpen-confirm' events and displays modal confirm dialogs. + * Manages a queue of pending dialogs - only one is shown at a time. * @element tpen-confirm-container */ class ConfirmContainer extends HTMLElement { #screenLockingSection #confirmElem + #dialogQueue = [] + #keydownHandler /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() @@ -20,9 +25,24 @@ class ConfirmContainer extends HTMLElement { } connectedCallback() { - const confirmHandler = ({ detail }) => this.addConfirm(detail?.message, detail?.positiveButtonText, detail.negativeButtonText) - const positiveHandler = () => this.#confirmElem?.dismiss() - const negativeHandler = () => this.#confirmElem?.dismiss() + const confirmHandler = ({ detail }) => this.addConfirm( + detail?.message, + detail?.positiveButtonText, + detail?.negativeButtonText, + detail?.confirmId + ) + const positiveHandler = ({ detail }) => { + const current = this.#dialogQueue[0] + if (current && detail?.confirmId === current.confirmId) { + this.dismissCurrent() + } + } + const negativeHandler = ({ detail }) => { + const current = this.#dialogQueue[0] + if (current && detail?.confirmId === current.confirmId) { + this.dismissCurrent() + } + } this.cleanup.onEvent(eventDispatcher, 'tpen-confirm', confirmHandler) this.cleanup.onEvent(eventDispatcher, 'tpen-confirm-positive', positiveHandler) @@ -35,131 +55,167 @@ class ConfirmContainer extends HTMLElement { /** * Add the confirm dialogue with positive and negative confirmation buttons. + * Dialogs are queued - only one is shown at a time. * * @params message {String} A message to show in the confirm dialogue. * @params positiveButtonText {String} The text label for the positive confirm button. * @params negativeButtonText {String} The text label for the negative confirm button. + * @params confirmId {String} Unique ID to prevent listener collisions on rapid confirms. */ - addConfirm(message, positiveButtonText, negativeButtonText) { + addConfirm(message, positiveButtonText, negativeButtonText, confirmId) { if (!message || typeof message !== 'string') return if (!positiveButtonText || typeof positiveButtonText !== 'string') positiveButtonText = 'Yes' if (!negativeButtonText || typeof negativeButtonText !== 'string') negativeButtonText = 'No' - const { matches: motionOK } = window.matchMedia('(prefers-reduced-motion: no-preference)') - const buttonContainer = document.createElement("div") - buttonContainer.classList.add("button-container") + + const buttonContainer = document.createElement('div') + buttonContainer.classList.add('button-container') const confirmElem = document.createElement('tpen-confirm') const confirmButton = document.createElement('button') - confirmButton.style.marginRight = "10px" const denyButton = document.createElement('button') + confirmElem.textContent = message confirmButton.textContent = positiveButtonText denyButton.textContent = negativeButtonText + const handlePositive = (e) => { - eventDispatcher.dispatch("tpen-confirm-positive") + eventDispatcher.dispatch('tpen-confirm-positive', { confirmId }) } const handleNegative = (e) => { - eventDispatcher.dispatch("tpen-confirm-negative") + eventDispatcher.dispatch('tpen-confirm-negative', { confirmId }) } + confirmButton.addEventListener('click', handlePositive) denyButton.addEventListener('click', handleNegative) buttonContainer.appendChild(confirmButton) buttonContainer.appendChild(denyButton) confirmElem.appendChild(buttonContainer) - this.#screenLockingSection.appendChild(confirmElem) - this.#confirmElem = confirmElem - confirmElem.show() + + const dialogEntry = { + elem: confirmElem, + buttons: { positive: confirmButton, negative: denyButton }, + confirmId: confirmId + } + + this.#dialogQueue.push(dialogEntry) + + // If this is the first dialog, show it immediately + if (this.#dialogQueue.length === 1) { + this.#showCurrent() + } } - render() { - const style = document.createElement('style') - // We copied the :root rules from /components/gui/site/index.css. Importing it was too much. - style.textContent = ` - :host { - --primary-color: hsl(186, 84%, 40%); - --primary-light: hsl(186, 84%, 60%); - --light-color : hsl(186, 84%, 90%); - --dark : #2d2d2d; - --white : hsl(0, 0%, 100%); - --gray : hsl(0, 0%, 60%); - --light-gray : hsl(0, 0%, 90%); - } - .confirm-area { - position: fixed; - display: grid; - z-index: 16; - inset-block-start: 0; - inset-inline: 0; - justify-items: center; - justify-content: center; - height: 0vh; - background-color: rgba(0,0,0,0.7); - opacity: 0; - transition: all 0.5s ease-in-out; - } - .confirm-area.show { - opacity: 1; - height: 100vh; - } - tpen-confirm { - z-index: 16; - display: block; - position: relative; - background-color: #333; - color: #fff; - padding: 10px 20px; - border-radius: 5px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); - opacity: 0.0; - height: fit-content; - min-width: 25vw; - max-width: 35vw; - transition: all 0.3s ease-in-out; - font-size: 14pt; - } - .confirm-area tpen-confirm { - top: 0px; - right: 0px; - } - @media (prefers-reduced-motion) { - .confirm-area tpen-confirm { - opacity: 1.0; - height: fit-content; - top: 5vh; - } - } - .confirm-area tpen-confirm.show { - opacity: 1.0; - height: fit-content; - top: 5vh; + /** + * Display the current (first) dialog in the queue and set focus. + */ + #showCurrent() { + if (this.#dialogQueue.length === 0) return + + const current = this.#dialogQueue[0] + this.#confirmElem = current.elem + + this.#screenLockingSection.appendChild(current.elem) + current.elem.show() + + // Set focus to negative/cancel button by default (UX best practice: avoid accidental destructive actions) + current.buttons.negative.focus() + + // Use native dialog cancel event for Escape key (cleaner than document keydown) + const cancelHandler = (e) => { + e.preventDefault() + current.buttons.negative.click() + } + this.#screenLockingSection.addEventListener('cancel', cancelHandler) + + // Attach keyboard handler for Tab/Enter navigation + if (this.#keydownHandler) { + document.removeEventListener('keydown', this.#keydownHandler) + } + this.#keydownHandler = (e) => this.#handleKeydown(e) + document.addEventListener('keydown', this.#keydownHandler) + + // Store cancel handler so it can be removed on dismiss + current.cancelHandler = cancelHandler + } + + /** + * Handle keyboard navigation in confirm dialogs. + * - Tab: cycle focus between buttons + * - Enter: activate focused button + * (Escape is handled via native dialog cancel event) + */ + #handleKeydown(e) { + if (!this.#confirmElem) return + + if (e.key === 'Enter') { + e.preventDefault() + // Traverse shadow DOM to find the actual focused element + let focused = document.activeElement + while (focused?.shadowRoot?.activeElement) { + focused = focused.shadowRoot.activeElement } - .confirm-area .button-container { - position: relative; - display: block; - text-align: right; - margin-top: 1vh; + if (focused?.tagName === 'BUTTON') { + focused.click() } - .confirm-area button { - position: relative; - display: inline-block; - cursor: pointer; - border: none; - padding: 10px 20px; - background-color: var(--primary-color); - outline: var(--primary-light) 1px solid; - outline-offset: -3.5px; - color: var(--white); - border-radius: 5px; - transition: all 0.3s; - font-size: 12pt; + return + } + + if (e.key === 'Tab') { + const buttons = this.#confirmElem.querySelectorAll('button') + if (buttons.length !== 2) return + + // Traverse shadow DOM for actual focus + let focused = document.activeElement + while (focused?.shadowRoot?.activeElement) { + focused = focused.shadowRoot.activeElement } - .confirm-area button:hover { - background-color: var(--primary-light); - outline: var(--primary-color) 1px solid; - outline-offset: -1.5px; + + e.preventDefault() + // Simple two-button cycle: Tab goes negative→positive, Shift+Tab goes positive→negative + if (e.shiftKey) { + const target = focused === buttons[0] ? buttons[1] : buttons[0] + target.focus() + } else { + const target = focused === buttons[1] ? buttons[0] : buttons[1] + target.focus() } - ` + } + } + + /** + * Dismiss the current dialog and show the next one in queue. + */ + dismissCurrent() { + if (this.#dialogQueue.length === 0) return + + const current = this.#dialogQueue.shift() + + // Remove the cancel event listener + if (current.cancelHandler) { + this.#screenLockingSection.removeEventListener('cancel', current.cancelHandler) + } + + current.elem.dismiss() + + // Remove keyboard handler + if (this.#keydownHandler) { + document.removeEventListener('keydown', this.#keydownHandler) + this.#keydownHandler = null + } + + // Show next dialog in queue if available + if (this.#dialogQueue.length > 0) { + setTimeout(() => this.#showCurrent(), 600) // Wait for dismiss animation + return + } + + closeModalHostWhenEmpty(this.#screenLockingSection, 'tpen-confirm') + } + + render() { + const style = document.createElement('style') + style.textContent = sharedModalStyles // This section will take over the screen and lock down screen interaction. It lives at the top of the viewport. - const screenLockingSection = document.createElement('section') + const screenLockingSection = document.createElement('dialog') screenLockingSection.classList.add('confirm-area') this.shadowRoot.innerHTML = '' @@ -172,5 +228,5 @@ class ConfirmContainer extends HTMLElement { // Guard against duplicate registration when module is loaded via different URL paths if (!customElements.get('tpen-confirm-container')) { customElements.define('tpen-confirm-container', ConfirmContainer) - document?.body.after(new ConfirmContainer()) + document.body.appendChild(new ConfirmContainer()) } diff --git a/components/gui/modal.css.js b/components/gui/modal.css.js new file mode 100644 index 00000000..13a0b4d9 --- /dev/null +++ b/components/gui/modal.css.js @@ -0,0 +1,180 @@ +/** + * Shared modal dialog stylesheet for alert/confirm containers. + * Defines common CSS custom properties, button styles, and backdrop patterns. + * + * Import with: new CSSStyleSheet(); sheet.replaceSync(sharedModalStyles) + * or: const style = document.createElement('style'); style.textContent = sharedModalStyles + */ +export const sharedModalStyles = ` + :host { + --primary-color: var(--primary-color, hsl(186, 84%, 40%)); + --primary-light: var(--primary-light, hsl(186, 84%, 60%)); + --light-color : var(--light-color, hsl(186, 84%, 90%)); + --dark : var(--dark, #2d2d2d); + --white : var(--white, hsl(0, 0%, 100%)); + --gray : var(--gray, hsl(0, 0%, 60%)); + --light-gray : var(--light-gray, hsl(0, 0%, 90%)); + } + + /* Modal host dialog — invisible until open */ + dialog[class*="area"] { + position: fixed; + inset-block-start: 0; + inset-inline: 0; + justify-items: center; + justify-content: center; + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + margin: 0; + padding: 0; + border: none; + background-color: transparent; + opacity: 0; + transition: all 0.5s ease-in-out; + /* Define color variables directly so they're available to all children */ + --primary-color: hsl(186, 84%, 40%); + --primary-light: hsl(186, 84%, 60%); + --light-color : hsl(186, 84%, 90%); + --dark : #2d2d2d; + --white : hsl(0, 0%, 100%); + --gray : hsl(0, 0%, 60%); + --light-gray : hsl(0, 0%, 90%); + } + + /* Only show grid layout when dialog is open */ + dialog[class*="area"][open] { + display: grid; + place-items: center; + } + + dialog[class*="area"]::backdrop { + background-color: rgba(0, 0, 0, 0.7); + } + + /* Fade in backdrop when shown */ + dialog[class*="area"].show { + opacity: 1; + } + + /* Modal message/content element (tpen-alert, tpen-confirm, etc) */ + tpen-alert, + tpen-confirm, + [role="alert"], + [role="alertdialog"] { + display: block; + position: relative; + background-color: #333; + color: #fff; + padding: 10px 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + opacity: 0; + height: fit-content; + min-width: 25vw; + max-width: 35vw; + transition: all 0.3s ease-in-out; + font-size: 14pt; + } + + /* Links inside modal message */ + tpen-alert a, + tpen-confirm a, + [role="alert"] a, + [role="alertdialog"] a { + color: var(--primary-color); + text-decoration: underline; + } + + /* Position message in top-right during animation */ + dialog[class*="area"] tpen-alert, + dialog[class*="area"] tpen-confirm, + dialog[class*="area"] [role="alert"], + dialog[class*="area"] [role="alertdialog"] { + top: 0px; + right: 0px; + } + + /* Reduced motion: show directly without animation */ + @media (prefers-reduced-motion: reduce) { + dialog[class*="area"] tpen-alert, + dialog[class*="area"] tpen-confirm, + dialog[class*="area"] [role="alert"], + dialog[class*="area"] [role="alertdialog"] { + opacity: 1; + height: fit-content; + top: 5vh; + } + } + + /* Button container for modal actions */ + .button-container { + position: relative; + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 10px; + margin-top: 1.5vh; + flex-wrap: wrap; + } + + /* Message becomes visible when .show class added */ + dialog[class*="area"] tpen-alert.show, + dialog[class*="area"] tpen-confirm.show, + dialog[class*="area"] [role="alert"].show, + dialog[class*="area"] [role="alertdialog"].show { + opacity: 1; + height: fit-content; + top: 5vh; + } + + /* Modal action buttons */ + dialog[class*="area"] button { + position: relative; + display: inline-block; + cursor: pointer; + border: none; + padding: 10px 20px; + background-color: var(--primary-color); + outline: var(--primary-light) 1px solid; + outline-offset: -3.5px; + color: var(--white); + border-radius: 5px; + transition: all 0.2s ease; + font-size: 12pt; + font-weight: 500; + min-width: 80px; + text-align: center; + } + + dialog[class*="area"] button:hover { + background-color: var(--primary-light); + outline: var(--primary-color) 2px solid; + outline-offset: -1px; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } + + dialog[class*="area"] button:focus-visible { + background-color: var(--primary-light); + outline: var(--white) 2.5px solid; + outline-offset: 1px; + } + + dialog[class*="area"] button:active { + transform: translateY(0px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + } + + /* Mobile responsive: wider dialogs on small screens */ + @media (max-width: 768px) { + tpen-alert, + tpen-confirm, + [role="alert"], + [role="alertdialog"] { + min-width: 80vw; + max-width: 90vw; + } + } +`; diff --git a/components/leave-project/index.js b/components/leave-project/index.js index e6527997..973866be 100644 --- a/components/leave-project/index.js +++ b/components/leave-project/index.js @@ -3,6 +3,7 @@ import { getAgentIRIFromToken } from '../iiif-tools/index.js' import CheckPermissions from '../check-permissions/checkPermissions.js' import { onProjectReady } from "../../utilities/projectReady.js" import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { confirmAction } from '../../utilities/confirmAction.js' /** * LeaveProject - Allows a user to leave a project they are a member of. @@ -140,51 +141,57 @@ class LeaveProject extends HTMLElement { } leaveProject() { - if (!confirm("You are leaving this TPEN3 project.")) return - let redir = true - const leaveBtn = this.shadowRoot.getElementById("leaveBtn") - leaveBtn.setAttribute("disabled", "disabled") - leaveBtn.setAttribute("value", "leaving...") + confirmAction( + "You are leaving this TPEN3 project.", + () => { + let redir = true + const leaveBtn = this.shadowRoot.getElementById("leaveBtn") + leaveBtn.setAttribute("disabled", "disabled") + leaveBtn.setAttribute("value", "leaving...") - fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/leave`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${TPEN.getAuthorization()}` - } - }) - .then(resp => { - if (resp.ok) return resp.text() - redir = false - return resp.json() - }) - .then(message => { - let userMessage = (typeof message === "string") ? message : message?.message - if (redir) { - this.shadowRoot.innerHTML = ` -

Now you are not a project member. Goodbye 👋

- ` - setTimeout(() => { - document.location.href = TPEN.BASEURL - }, 3000) - return - } - this.shadowRoot.innerHTML = ` -

There was an error leaving the project.

-

- The message below has more details. - Refresh the page to try again or contact the TPEN3 Administrators. -

- ${userMessage} - ` - }) - .catch(err => { - this.shadowRoot.innerHTML = ` -

- There was an error leaving the project. Refresh the page to try again - or contact the TPEN3 Administrators. -

- ` - }) + fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/leave`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TPEN.getAuthorization()}` + } + }) + .then(resp => { + if (resp.ok) return resp.text() + redir = false + return resp.json() + }) + .then(message => { + let userMessage = (typeof message === "string") ? message : message?.message + if (redir) { + this.shadowRoot.innerHTML = ` +

Now you are not a project member. Goodbye 👋

+ ` + setTimeout(() => { + document.location.href = TPEN.BASEURL + }, 3000) + return + } + this.shadowRoot.innerHTML = ` +

There was an error leaving the project.

+

+ The message below has more details. + Refresh the page to try again or contact the TPEN3 Administrators. +

+ ${userMessage} + ` + }) + .catch(err => { + this.shadowRoot.innerHTML = ` +

+ There was an error leaving the project. Refresh the page to try again + or contact the TPEN3 Administrators. +

+ ` + }) + }, + null, + { positiveButtonText: "Leave", negativeButtonText: "Cancel" } + ) } } diff --git a/components/manage-layers/index.js b/components/manage-layers/index.js index 723ad4e2..ef734bb6 100644 --- a/components/manage-layers/index.js +++ b/components/manage-layers/index.js @@ -3,6 +3,7 @@ import "../../components/manage-pages/index.js" import CheckPermissions from "../../components/check-permissions/checkPermissions.js" import { onProjectReady } from "../../utilities/projectReady.js" import { CleanupRegistry } from "../../utilities/CleanupRegistry.js" +import { confirmAction } from "../../utilities/confirmAction.js" /** * ProjectLayers - Manages project layers including creation, deletion, and page management. @@ -244,24 +245,29 @@ class ProjectLayers extends HTMLElement { }) return } - if (!confirm("This Layer will be deleted and the Pages will no longer be a part of this project. This action cannot be undone.")) return const url = event.target.getAttribute("data-layer-id") const layerId = url.substring(url.lastIndexOf("/") + 1) - - fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/layer/${layerId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TPEN.getAuthorization()}` - } - }) - .then(response => { - return TPEN.eventDispatcher.dispatch("tpen-toast", - response.ok ? - { status: "info", message: 'Successfully Deleted Layer' } : - { status: "error", message: 'Error Deleting Layer' } - ) - }) + confirmAction( + "This Layer will be deleted and the Pages will no longer be a part of this project. This action cannot be undone.", + () => { + fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/layer/${layerId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${TPEN.getAuthorization()}` + } + }) + .then(response => { + return TPEN.eventDispatcher.dispatch("tpen-toast", + response.ok ? + { status: "info", message: 'Successfully Deleted Layer' } : + { status: "error", message: 'Error Deleting Layer' } + ) + }) + }, + null, + { positiveButtonText: "Delete", negativeButtonText: "Cancel" } + ) }) }) diff --git a/components/manage-pages/index.js b/components/manage-pages/index.js index 3e632dbb..8174d7a7 100644 --- a/components/manage-pages/index.js +++ b/components/manage-pages/index.js @@ -2,6 +2,7 @@ import TPEN from "../../api/TPEN.js" import CheckPermissions from "../../components/check-permissions/checkPermissions.js" import { onProjectReady } from "../../utilities/projectReady.js" import { CleanupRegistry } from "../../utilities/CleanupRegistry.js" +import { confirmAction } from "../../utilities/confirmAction.js" /** * ManagePages - Provides UI for managing pages within a layer including reordering, editing labels, and deletion. @@ -285,13 +286,19 @@ class ManagePages extends HTMLElement { }) return } - if (!confirm("This Page will be removed from this layer and deleted. This action cannot be undone.")) return - layerCardOuter.querySelector(".layer-pages").removeChild(el) - layers[layerIndex].pages.splice(el.dataset.index, 1) - mainParent.shadowRoot.querySelectorAll(`.layer-card-outer[data-index="${layerIndex}"] .layer-page`).forEach((card, newIndex) => { - card.dataset.index = newIndex - }) - layerPagesCard = mainParent.shadowRoot.querySelectorAll(`.layer-card-outer[data-index="${layerIndex}"] .layer-page`) + confirmAction( + "This Page will be removed from this layer and deleted. This action cannot be undone.", + () => { + layerCardOuter.querySelector(".layer-pages").removeChild(el) + layers[layerIndex].pages.splice(el.dataset.index, 1) + mainParent.shadowRoot.querySelectorAll(`.layer-card-outer[data-index="${layerIndex}"] .layer-page`).forEach((card, newIndex) => { + card.dataset.index = newIndex + }) + layerPagesCard = mainParent.shadowRoot.querySelectorAll(`.layer-card-outer[data-index="${layerIndex}"] .layer-page`) + }, + null, + { positiveButtonText: "Delete", negativeButtonText: "Cancel" } + ) }) }) diff --git a/components/manifest-import/index.js b/components/manifest-import/index.js index 5045bacf..65cfae4a 100644 --- a/components/manifest-import/index.js +++ b/components/manifest-import/index.js @@ -7,6 +7,7 @@ import TPEN from '../../api/TPEN.js' import { escapeHtml } from '/js/utils.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { confirmAction } from '../../utilities/confirmAction.js' class ManifestImport extends HTMLElement { #manifests = [] @@ -60,13 +61,20 @@ class ManifestImport extends HTMLElement { // For multiple manifests, require explicit user confirmation before starting import if (this.#manifests.length > 1) { const confirmMessage = `You are about to import ${this.#manifests.length} manifests as new projects. Do you want to continue?` - const proceed = window.confirm(confirmMessage) - if (!proceed) { - if (this.shadowRoot) { - this.shadowRoot.innerHTML = '

Manifest import canceled by user.

' - } - return - } + confirmAction( + confirmMessage, + () => { + this.renderCreating() + this.#createProjects() + }, + () => { + if (this.shadowRoot) { + this.shadowRoot.innerHTML = '

Manifest import canceled by user.

' + } + }, + { positiveButtonText: "Import", negativeButtonText: "Cancel" } + ) + return } this.renderCreating() await this.#createProjects() diff --git a/components/navigation-manager/index.js b/components/navigation-manager/index.js index de9ce9ce..6473f004 100644 --- a/components/navigation-manager/index.js +++ b/components/navigation-manager/index.js @@ -3,6 +3,7 @@ import { escapeHtml } from '/js/utils.js' import CheckPermissions from '../check-permissions/checkPermissions.js' import { onProjectReady } from "../../utilities/projectReady.js" import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { confirmAction } from '../../utilities/confirmAction.js' /** * NavigationManager - Interface for customizing project navigation URLs. @@ -101,15 +102,20 @@ class NavigationManager extends HTMLElement { } resetToDefaults() { - if (confirm('Reset all navigation URLs to defaults?')) { - this._navigation = { - transcribe: '', - defineLines: '', - manageProject: '' - } - this.render() - this.addEventListeners() - } + confirmAction( + 'Reset all navigation URLs to defaults?', + () => { + this._navigation = { + transcribe: '', + defineLines: '', + manageProject: '' + } + this.render() + this.addEventListeners() + }, + null, + { positiveButtonText: "Reset", negativeButtonText: "Cancel" } + ) } render() { diff --git a/components/project-tools/index.js b/components/project-tools/index.js index 6c73699e..781ac12e 100644 --- a/components/project-tools/index.js +++ b/components/project-tools/index.js @@ -74,16 +74,24 @@ class ProjectTools extends HTMLElement { user-select: none; } .modal { - display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; + background-color: transparent; + margin: 0; + padding: 0; + border: none; + max-width: 100vw; + max-height: 100vh; + } + .modal[open] { + display: grid; + place-items: center; + } + .modal::backdrop { background-color: rgba(0, 0, 0, 0.5); - justify-content: center; - align-items: center; - z-index: 1000; } .modal-content { background: #fff; @@ -199,7 +207,7 @@ class ProjectTools extends HTMLElement { ` : ""} `).join("")} ${isToolsEditAccess ? ` - - ` : ""} + ` : ""} ` @@ -257,14 +265,27 @@ class ProjectTools extends HTMLElement { this.renderCleanup.run() this.renderCleanup.onElement(openModalBtn, "click", () => { - modal.style.display = "flex" + if (!modal.open) { + modal.showModal() + } iframe.style.display = "none" nameInput.value = "" urlInput.value = "" }) this.renderCleanup.onElement(closeModalBtn, "click", () => { - modal.style.display = "none" + modal.close() + }) + + this.renderCleanup.onElement(modal, "cancel", (e) => { + e.preventDefault() + modal.close() + }) + + this.renderCleanup.onElement(modal, "click", (e) => { + if (e.target === modal) { + modal.close() + } }) this.renderCleanup.onElement(testBtn, "click", () => { @@ -298,7 +319,7 @@ class ProjectTools extends HTMLElement { return TPEN.eventDispatcher.dispatch("tpen-toast", { status: "error", message: 'Please enter a valid URL' }) if (checkTools(name, url)) { - modal.style.display = "none" + modal.close() iframe.style.display = "none" nameInput.value = "" urlInput.value = "" @@ -323,7 +344,7 @@ class ProjectTools extends HTMLElement { }) }) - modal.style.display = "none" + modal.close() iframe.style.display = "none" nameInput.value = "" urlInput.value = "" @@ -361,7 +382,7 @@ class ProjectTools extends HTMLElement { body: JSON.stringify({ toolName }) }) - modal.style.display = "none" + modal.close() iframe.style.display = "none" nameInput.value = "" urlInput.value = "" diff --git a/components/projects/index.js b/components/projects/index.js index 4b0ba559..7f8cb289 100644 --- a/components/projects/index.js +++ b/components/projects/index.js @@ -96,7 +96,7 @@ export default class ProjectsList extends HTMLElement { this.querySelectorAll('.delete-btn').forEach(button => { this.renderCleanup.onElement(button, "click", (event) => { const projectId = event.target.getAttribute("data-project-id") - alert(`Delete not implemented for project ID: ${projectId}`) + TPEN.eventDispatcher.dispatch('tpen-alert', { message: `Delete not implemented for project ID: ${projectId}` }) }) }) } diff --git a/components/projects/project-list-write.js b/components/projects/project-list-write.js index 6789ce74..9d77ad6f 100644 --- a/components/projects/project-list-write.js +++ b/components/projects/project-list-write.js @@ -91,7 +91,7 @@ export default class ProjectsManager extends HTMLElement { this.querySelectorAll('.delete-btn').forEach(button => { this.renderCleanup.onElement(button, "click", (event) => { const projectId = event.target.getAttribute("data-project-id") - alert(`Delete not implemented for project ID: ${projectId}`) + eventDispatcher.dispatch('tpen-alert', { message: `Delete not implemented for project ID: ${projectId}` }) }) }) } diff --git a/components/quicktype-manager/index.js b/components/quicktype-manager/index.js index da660686..2b324601 100644 --- a/components/quicktype-manager/index.js +++ b/components/quicktype-manager/index.js @@ -4,6 +4,7 @@ import { evaluateEntry } from '../quicktype/validation.js' import '../quicktype-tool/quicktype-editor-dialog.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' import { onProjectReady } from '../../utilities/projectReady.js' +import { confirmAction } from '../../utilities/confirmAction.js' export const PRESET_COLLECTIONS = { 'Old English': ['Þ', 'þ', 'Ð', 'ð', 'Æ', 'æ', 'Ȝ', 'ȝ'], @@ -357,11 +358,16 @@ class QuickTypeManager extends HTMLElement { // Clear all const clearBtn = this.shadowRoot.querySelector('#clear-btn') this.renderCleanup.onElement(clearBtn, 'click', () => { - if (confirm('Are you sure you want to clear all shortcuts?')) { - this._shortcuts = [] - this.render() - this.addEventListeners() - } + confirmAction( + "Are you sure you want to clear all shortcuts?", + () => { + this._shortcuts = [] + this.render() + this.addEventListeners() + }, + null, + { positiveButtonText: "Clear", negativeButtonText: "Cancel" } + ) }) // Save diff --git a/components/quicktype-tool/quicktype-editor-dialog.js b/components/quicktype-tool/quicktype-editor-dialog.js index acf7a305..f32dd96d 100644 --- a/components/quicktype-tool/quicktype-editor-dialog.js +++ b/components/quicktype-tool/quicktype-editor-dialog.js @@ -51,8 +51,9 @@ class QuickTypeEditorDialog extends HTMLElement { const overlay = this.shadowRoot.querySelector('.dialog-overlay') const container = this.shadowRoot.querySelector('.dialog-container') - overlay.style.display = 'flex' - // Trigger reflow + if (!overlay.open) { + overlay.showModal() + } overlay.offsetHeight overlay.classList.add('show') container.classList.add('show') @@ -71,7 +72,7 @@ class QuickTypeEditorDialog extends HTMLElement { if (this.#closeAnimationTimeout) clearTimeout(this.#closeAnimationTimeout) this.#closeAnimationTimeout = setTimeout(() => { this.#closeAnimationTimeout = null - overlay.style.display = 'none' + overlay?.close?.() }, 300) } @@ -297,7 +298,7 @@ class QuickTypeEditorDialog extends HTMLElement { const isEmpty = this._quicktype.length === 0 const overlay = this.shadowRoot.querySelector('.dialog-overlay') const container = this.shadowRoot.querySelector('.dialog-container') - const wasOverlayVisible = overlay?.classList.contains('show') ?? false + const wasOverlayVisible = overlay?.open ?? false const wasContainerVisible = container?.classList.contains('show') ?? false const isNewItemAdded = this._quicktype.length > this._previousLength @@ -336,7 +337,9 @@ class QuickTypeEditorDialog extends HTMLElement { const container = this.shadowRoot.querySelector('.dialog-container') if (wasOverlayVisible && overlay) { - overlay.style.display = 'flex' + if (!overlay.open) { + overlay.showModal() + } overlay.offsetHeight overlay.classList.add('show') } @@ -382,28 +385,32 @@ class QuickTypeEditorDialog extends HTMLElement { this.shadowRoot.innerHTML = ` -
+

Edit QuickType Shortcuts

@@ -758,7 +765,7 @@ class QuickTypeEditorDialog extends HTMLElement {
-
+ ` } diff --git a/components/roles-handler/index.js b/components/roles-handler/index.js index 09a49286..bbf19041 100644 --- a/components/roles-handler/index.js +++ b/components/roles-handler/index.js @@ -2,6 +2,7 @@ import TPEN from "../../api/TPEN.js" import CheckPermissions from '../../components/check-permissions/checkPermissions.js' import { onProjectReady } from "../../utilities/projectReady.js" import { CleanupRegistry } from "../../utilities/CleanupRegistry.js" +import { confirmAction } from "../../utilities/confirmAction.js" /** * RolesHandler - Manages role assignment UI for project collaborators. @@ -492,7 +493,7 @@ class RolesHandler extends HTMLElement { } } catch (error) { console.error("Error handling button action:", error) - alert("An error occurred. Please try again.") + TPEN.eventDispatcher.dispatch('tpen-alert', { message: "An error occurred. Please try again." }) } } @@ -641,8 +642,8 @@ class RolesHandler extends HTMLElement { // Transfer ownership button if (!isOwner && currentUserIsOwner) { transferBtn.classList.remove("hidden") - this.renderCleanup.onElement(transferBtn, 'click', async () => { - await this.handleTransferOwnership(memberID, memberName) + this.renderCleanup.onElement(transferBtn, 'click', () => { + this.handleTransferOwnership(memberID, memberName) }) } else { transferBtn.classList.add("hidden") @@ -651,8 +652,8 @@ class RolesHandler extends HTMLElement { // Remove collaborator button if (hasDeleteAccess) { removeBtn.classList.remove("hidden") - this.renderCleanup.onElement(removeBtn, 'click', async () => { - await this.handleRemoveMember(memberID, memberName) + this.renderCleanup.onElement(removeBtn, 'click', () => { + this.handleRemoveMember(memberID, memberName) }) } else { removeBtn.classList.add("hidden") @@ -739,45 +740,61 @@ class RolesHandler extends HTMLElement { const originalSelection = this.originalRoles.sort().join(",") if (currentSelection !== originalSelection) { - if (confirm("You have unsaved changes. Discard changes and close?")) { - this.closeRoleModal() - } + confirmAction( + "You have unsaved changes. Discard changes and close?", + () => this.closeRoleModal(), + null, + { positiveButtonText: "Discard", negativeButtonText: "Keep Editing" } + ) } else { this.closeRoleModal() } } - async handleTransferOwnership(memberID, memberName) { + handleTransferOwnership(memberID, memberName) { const confirmMessage = `You are about to transfer ownership of this project to ${memberName}. This action is irreversible. Please confirm if you want to proceed.` - if (!window.confirm(confirmMessage)) return - - try { - const response = await TPEN.activeProject.transferOwnership(memberID) - if (response) { - TPEN.eventDispatcher.dispatch('tpen-toast', { message: 'Ownership transferred successfully.', status: 'success' }) - location.reload() - } - } catch (error) { - console.error("Error transferring ownership:", error) - const errorMessage = this.getErrorMessage(error) - TPEN.eventDispatcher.dispatch('tpen-toast', { message: errorMessage, status: 'error', dismissible: true }) - } + confirmAction( + confirmMessage, + () => { + TPEN.activeProject.transferOwnership(memberID) + .then(response => { + if (response) { + TPEN.eventDispatcher.dispatch('tpen-toast', { message: 'Ownership transferred successfully.', status: 'success' }) + location.reload() + } + }) + .catch(error => { + console.error("Error transferring ownership:", error) + const errorMessage = this.getErrorMessage(error) + TPEN.eventDispatcher.dispatch('tpen-toast', { message: errorMessage, status: 'error', dismissible: true }) + }) + }, + null, + { positiveButtonText: "Transfer", negativeButtonText: "Cancel" } + ) } - async handleRemoveMember(memberID, memberName) { - if (!confirm(`This action will remove ${memberName} from your project. Click 'OK' to continue?`)) return - try { - const data = await TPEN.activeProject.removeMember(memberID) - if (data) { - TPEN.eventDispatcher.dispatch('tpen-toast', { message: 'Member removed successfully', status: 'success' }) - this.closeRoleModal() - this.refreshCollaborators() - } - } catch (error) { - console.error("Error removing member:", error) - const errorMessage = this.getErrorMessage(error) - TPEN.eventDispatcher.dispatch('tpen-toast', { message: errorMessage, status: 'error', dismissible: true }) - } + handleRemoveMember(memberID, memberName) { + confirmAction( + `This action will remove ${memberName} from your project. Do you want to continue?`, + () => { + TPEN.activeProject.removeMember(memberID) + .then(data => { + if (data) { + TPEN.eventDispatcher.dispatch('tpen-toast', { message: 'Member removed successfully', status: 'success' }) + this.closeRoleModal() + this.refreshCollaborators() + } + }) + .catch(error => { + console.error("Error removing member:", error) + const errorMessage = this.getErrorMessage(error) + TPEN.eventDispatcher.dispatch('tpen-toast', { message: errorMessage, status: 'error', dismissible: true }) + }) + }, + null, + { positiveButtonText: "Remove", negativeButtonText: "Cancel" } + ) } closeRoleModal() { diff --git a/components/update-metadata/index.css b/components/update-metadata/index.css index 898025d5..1fce1958 100644 --- a/components/update-metadata/index.css +++ b/components/update-metadata/index.css @@ -15,16 +15,30 @@ } #metadata-modal { - width: 70%; - display: flex; - align-items: center; - justify-content: center; + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + margin: 0; + border: none; + background: transparent; + padding: 0; +} + +#metadata-modal[open] { + display: grid; + place-items: center; +} + +#metadata-modal::backdrop { + background-color: rgba(0, 0, 0, 0.35); } .modal-content { padding: 20px; border-radius: 8px; - width: 100%; + width: 70%; + background-color: #fff; } .modal-content h3 { @@ -84,6 +98,10 @@ } #metadata-modal { + width: 100vw; + } + + .modal-content { width: 100%; } -} \ No newline at end of file +} diff --git a/components/update-metadata/index.html b/components/update-metadata/index.html index b3b84212..6dc7455a 100644 --- a/components/update-metadata/index.html +++ b/components/update-metadata/index.html @@ -21,7 +21,7 @@ @@ -65,4 +65,4 @@

Edit Metadata

}) - \ No newline at end of file + diff --git a/components/update-metadata/index.js b/components/update-metadata/index.js index cd7b42ff..40b341d8 100644 --- a/components/update-metadata/index.js +++ b/components/update-metadata/index.js @@ -75,6 +75,21 @@ class UpdateMetadata extends HTMLElement { this.renderCleanup.onElement(document.getElementById("save-metadata-btn"), "click", () => { this.updateMetadata() }) + + this.renderCleanup.onElement(document.getElementById("cancel-btn"), "click", () => { + document.getElementById("metadata-modal")?.close?.() + }) + + this.renderCleanup.onElement(document.getElementById("metadata-modal"), "cancel", (e) => { + e.preventDefault() + document.getElementById("metadata-modal")?.close?.() + }) + + this.renderCleanup.onElement(document.getElementById("metadata-modal"), "click", (e) => { + if (e.target === e.currentTarget) { + document.getElementById("metadata-modal")?.close?.() + } + }) } openModal() { @@ -141,7 +156,9 @@ class UpdateMetadata extends HTMLElement { } }) - modal.classList.remove("hidden") + if (!modal.open) { + modal.showModal() + } } addMetadataField(lang = "none", label = "", value = "", index = null) { diff --git a/interfaces/manage-project/index.js b/interfaces/manage-project/index.js index 21adad09..a4367536 100644 --- a/interfaces/manage-project/index.js +++ b/interfaces/manage-project/index.js @@ -8,6 +8,7 @@ import "../../components/project-export/index.js" import "../../components/project-layers/index.js" import "../../components/project-tools/index.js" import CheckPermissions from "../../components/check-permissions/checkPermissions.js" +import { confirmAction } from "../../utilities/confirmAction.js" const container = document.body TPEN.attachAuthentication(container) @@ -46,29 +47,38 @@ TPEN.eventDispatcher.on('tpen-project-loaded', () => { applyProjectContext() }) -document.getElementById('export-project-btn').addEventListener('click', async () => { - if (!confirm('This will publish a new Manifest which will be available to the public.')) return - await fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/manifest`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${TPEN.getAuthorization()}` - } - }).then(response => { - return TPEN.eventDispatcher.dispatch("tpen-toast", - response.ok ? - { status: "info", message: 'Successfully Exported Project Manifest' } : - { status: "error", message: 'Error Exporting Project Manifest' } - ) - }).catch(error => { - console.error('Error exporting project manifest:', error) - }) +document.getElementById('export-project-btn').addEventListener('click', () => { + confirmAction( + "This will publish a new Manifest which will be available to the public.", + () => { + fetch(`${TPEN.servicesURL}/project/${TPEN.activeProject._id}/manifest`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${TPEN.getAuthorization()}` + } + }).then(response => { + return TPEN.eventDispatcher.dispatch("tpen-toast", + response.ok ? + { status: "info", message: 'Successfully Exported Project Manifest' } : + { status: "error", message: 'Error Exporting Project Manifest' } + ) + }).catch(error => { + console.error('Error exporting project manifest:', error) + }) + }, + null, + { positiveButtonText: "Publish", negativeButtonText: "Cancel" } + ) }) function applyProjectContext() { const isManageProjectPermission = CheckPermissions.checkEditAccess('PROJECT') if(!isManageProjectPermission) { - alert("You do not have permissions to use this page.") - document.location.href = `/project?projectID=${TPEN.screen.projectInQuery}` + TPEN.eventDispatcher.dispatch('tpen-alert', { message: "You do not have permissions to use this page." }) + TPEN.eventDispatcher.one('tpen-alert-acknowledged', () => { + document.location.href = `/project?projectID=${TPEN.screen.projectInQuery}` + }) + return } document.querySelector('tpen-project-details').setAttribute('tpen-project-id', TPEN.screen.projectInQuery) diff --git a/interfaces/transcription/index.js b/interfaces/transcription/index.js index ec4b739d..ab215c0d 100644 --- a/interfaces/transcription/index.js +++ b/interfaces/transcription/index.js @@ -562,16 +562,8 @@ export default class TranscriptionInterface extends HTMLElement { alertElem.querySelector('#no-lines-ok').addEventListener('click', () => { alertElem.dismiss() }) - - if (typeof alertContainer.addCustomAlert === 'function') { - alertContainer.addCustomAlert(alertElem) - } else { - const screenLockingSection = alertContainer.shadowRoot.querySelector('.alert-area') - if (screenLockingSection) { - screenLockingSection.appendChild(alertElem) - alertElem.show() - } - } + + alertContainer.addCustomAlert(alertElem) } updateLines() { diff --git a/utilities/confirmAction.js b/utilities/confirmAction.js new file mode 100644 index 00000000..f88c6303 --- /dev/null +++ b/utilities/confirmAction.js @@ -0,0 +1,37 @@ +import { eventDispatcher } from '../api/events.js' + +/** + * Shows a modal confirm dialog using the tpen-confirm event system. + * Uses a unique confirmId so concurrent dialogs do not cross-fire each + * other's callbacks — each response event is matched back to the dialog + * that produced it. + * + * @param {string} message - The message to display. + * @param {function} onConfirm - Called when the user clicks the positive button. + * @param {function} [onCancel] - Called when the user clicks the negative button. + * @param {object} [options] - Additional options forwarded to tpen-confirm. + * @param {string} [options.positiveButtonText] - Label for the positive button (default: 'Yes'). + * @param {string} [options.negativeButtonText] - Label for the negative button (default: 'No'). + */ +export function confirmAction(message, onConfirm, onCancel, options = {}) { + const confirmId = typeof crypto?.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + + const onPositive = (ev) => { + if (ev.detail?.confirmId !== confirmId) return + eventDispatcher.off('tpen-confirm-positive', onPositive) + eventDispatcher.off('tpen-confirm-negative', onNegative) + onConfirm() + } + const onNegative = (ev) => { + if (ev.detail?.confirmId !== confirmId) return + eventDispatcher.off('tpen-confirm-positive', onPositive) + eventDispatcher.off('tpen-confirm-negative', onNegative) + onCancel?.() + } + + eventDispatcher.on('tpen-confirm-positive', onPositive) + eventDispatcher.on('tpen-confirm-negative', onNegative) + eventDispatcher.dispatch('tpen-confirm', { message, confirmId, ...options }) +} diff --git a/utilities/modalHost.js b/utilities/modalHost.js new file mode 100644 index 00000000..00e234ea --- /dev/null +++ b/utilities/modalHost.js @@ -0,0 +1,51 @@ +/** + * Opens a dialog host in the browser top-layer if it is not already open. + * @param {HTMLDialogElement} hostDialog - Dialog element used as the modal host. + */ +export function openModalHost(hostDialog) { + if (!hostDialog?.showModal || hostDialog.open) return + hostDialog.showModal() +} + +/** + * Closes a dialog host and removes its visible state class. + * @param {HTMLDialogElement} hostDialog - Dialog element used as the modal host. + */ +export function closeModalHost(hostDialog) { + hostDialog?.classList.remove('show') + if (hostDialog?.open) { + hostDialog.close() + } +} + +/** + * Closes a dialog host once no matching modal elements remain. + * Retries briefly to avoid animation timing races. + * + * @param {HTMLDialogElement} hostDialog - Dialog element used as the modal host. + * @param {string} itemSelector - Selector for dialog items that may still be animating out. + * @param {object} [options] - Timing options. + * @param {number} [options.initialDelay=550] - Delay before first empty-check. + * @param {number} [options.interval=120] - Delay between retries. + * @param {number} [options.attempts=6] - Number of retries before giving up. + */ +export function closeModalHostWhenEmpty(hostDialog, itemSelector, options = {}) { + const { + initialDelay = 550, + interval = 120, + attempts = 6 + } = options + + const closeWhenEmpty = (attemptsRemaining = attempts) => { + const hasItems = hostDialog?.querySelector(itemSelector) + if (hasItems && attemptsRemaining > 0) { + setTimeout(() => closeWhenEmpty(attemptsRemaining - 1), interval) + return + } + + if (hasItems) return + closeModalHost(hostDialog) + } + + setTimeout(() => closeWhenEmpty(), initialDelay) +}