diff --git a/boulder_base.libraries.yml b/boulder_base.libraries.yml index 85a65c93..e0f66abf 100644 --- a/boulder_base.libraries.yml +++ b/boulder_base.libraries.yml @@ -570,3 +570,11 @@ ucb-trusted-content-preview: css: theme: css/block/ucb-trusted-content-block.css : {weight: 5} + +ucb-tos-acceptance: + version: 1.x + js: + js/ucb-tos-acceptance.js: { } + css: + theme: + css/ucb-tos-acceptance.css: { weight: 5 } diff --git a/css/ucb-tos-acceptance.css b/css/ucb-tos-acceptance.css new file mode 100644 index 00000000..3fd31c57 --- /dev/null +++ b/css/ucb-tos-acceptance.css @@ -0,0 +1,133 @@ +/* TOS Acceptance Modal Styles */ +.ucb-tos-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.ucb-tos-modal[hidden] { + display: none; +} + +.ucb-tos-modal.ucb-tos-modal-visible { + opacity: 1; + visibility: visible; +} + +.ucb-tos-modal-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1; +} + +.ucb-tos-modal-dialog { + position: relative; + z-index: 2; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + background: #fff; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transform: scale(0.9); + transition: transform 0.3s ease; +} + +.ucb-tos-modal.ucb-tos-modal-visible .ucb-tos-modal-dialog { + transform: scale(1); +} + +.ucb-tos-modal-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.ucb-tos-modal-header { + padding: 1.5rem; + border-bottom: 1px solid #e0e0e0; +} + +.ucb-tos-modal-title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.ucb-tos-modal-body { + padding: 1.5rem; + flex: 1; + overflow-y: auto; +} + +.ucb-tos-modal-body p { + margin-bottom: 1rem; +} + +.ucb-tos-modal-body a { + color: #005fcc; + text-decoration: underline; +} + +.ucb-tos-modal-body a:hover, +.ucb-tos-modal-body a:focus { + color: #004499; +} + +.ucb-tos-modal-footer { + padding: 1.5rem; + border-top: 1px solid #e0e0e0; + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +.ucb-tos-accept-button { + min-width: 120px; +} + +.ucb-tos-accept-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Hide aria-live region visually but keep it accessible to screen readers */ +.ucb-tos-modal-live-region { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .ucb-tos-modal-dialog { + width: 95%; + max-height: 95vh; + } + + .ucb-tos-modal-header, + .ucb-tos-modal-body, + .ucb-tos-modal-footer { + padding: 1rem; + } + + .ucb-tos-modal-title { + font-size: 1.25rem; + } +} diff --git a/js/ucb-tos-acceptance.js b/js/ucb-tos-acceptance.js new file mode 100644 index 00000000..65c6b37b --- /dev/null +++ b/js/ucb-tos-acceptance.js @@ -0,0 +1,273 @@ +/** + * @file + * TOS Acceptance modal behaviors. + */ +(function (Drupal) { + 'use strict'; + + // Store original body overflow style to restore later. + let originalBodyOverflow = ''; + + Drupal.behaviors.ucbTosAcceptance = { + attach: function (context, settings) { + // Check if we should show the TOS modal. + const tosData = settings.ucb_tos_acceptance; + if (!tosData || !tosData.show_modal) { + return; + } + + // Create modal if it doesn't exist. + let modal = document.querySelector('.ucb-tos-modal'); + if (!modal) { + modal = createTosModal(tosData.tos_url); + document.body.appendChild(modal); + + // Attach event listeners only once when modal is created. + attachEventListeners(modal); + } + + // Show the modal. + showModal(modal); + } + }; + + /** + * Attaches event listeners to the modal. + */ + function attachEventListeners(modal) { + const dialog = modal.querySelector('.ucb-tos-modal-dialog'); + const acceptButton = modal.querySelector('.ucb-tos-accept-button'); + + // Handle accept button. + if (acceptButton) { + acceptButton.addEventListener('click', function(e) { + e.preventDefault(); + handleAcceptance(acceptButton, modal); + }); + } + + // Implement focus trap for keyboard navigation. + if (dialog) { + dialog.addEventListener('keydown', function(e) { + handleFocusTrap(e, dialog); + }); + } + + // Note: Close button, backdrop click, and Escape key handlers removed + // to force users to accept TOS before continuing. + } + + /** + * Handles focus trapping within the modal. + */ + function handleFocusTrap(e, dialog) { + // Get all focusable elements within the modal. + const focusableElements = dialog.querySelectorAll( + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + // If Tab is pressed and focus is on the last element, loop to first. + if (e.key === 'Tab' && !e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + // If Shift+Tab is pressed and focus is on the first element, loop to last. + else if (e.key === 'Tab' && e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } + + /** + * Creates the TOS modal HTML structure. + */ + function createTosModal(tosUrl) { + const modal = document.createElement('div'); + modal.className = 'ucb-tos-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-labelledby', 'ucb-tos-modal-title'); + modal.setAttribute('aria-describedby', 'ucb-tos-modal-description'); + modal.setAttribute('aria-modal', 'true'); + + // Create aria-live region for status updates. + const liveRegion = document.createElement('div'); + liveRegion.setAttribute('role', 'status'); + liveRegion.setAttribute('aria-live', 'polite'); + liveRegion.setAttribute('aria-atomic', 'true'); + liveRegion.className = 'ucb-tos-modal-live-region'; + liveRegion.style.cssText = 'position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;'; + document.body.appendChild(liveRegion); + + modal.innerHTML = ` +
+