fix(forms): close completed drawers and restore portal styling
This commit is contained in:
15 files changed
+598
-9
No files matched your search
@@ -96,6 +96,7 @@ const headingId = `${idBase}-heading`;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
frame.removeEventListener('load', onLoad);
|
||||
container.removeEventListener('aia:jotform-activate', activate);
|
||||
container.removeEventListener('aia:jotform-reset', reset);
|
||||
window.removeEventListener('pagehide', dispose);
|
||||
document.removeEventListener('astro:before-swap', dispose);
|
||||
delete container.dataset.jotformFallbackInitialized;
|
||||
@@ -118,6 +119,20 @@ const headingId = `${idBase}-heading`;
|
||||
container.dataset.jotformIframeActivated = 'true';
|
||||
};
|
||||
|
||||
// A completed drawer is renewed only on a later deliberate opening.
|
||||
// Replacing the frame also invalidates messages from its old WindowProxy.
|
||||
const reset = () => {
|
||||
if (container.dataset.jotformDeferred !== 'true' || container.dataset.formContext !== 'consultation') return;
|
||||
dispose();
|
||||
const freshFrame = frame.cloneNode(false);
|
||||
freshFrame.removeAttribute('src');
|
||||
frame.replaceWith(freshFrame);
|
||||
delete container.dataset.jotformDocumentLoaded;
|
||||
delete container.dataset.jotformIframeActivated;
|
||||
initialize(container);
|
||||
};
|
||||
container.addEventListener('aia:jotform-reset', reset);
|
||||
|
||||
if (container.dataset.jotformDeferred === 'true') {
|
||||
container.addEventListener('aia:jotform-activate', activate, { once: true });
|
||||
} else {
|
||||
|
||||
@@ -169,6 +169,9 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
|
||||
|
||||
{consultationTestEnabled
|
||||
? <div class="service-inquiry-drawer__iframe">
|
||||
<p data-service-inquiry-completed hidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{lang === 'es' ? '¡Gracias! Hemos recibido su mensaje.' : 'Thank you! Your message has been received.'}
|
||||
</p>
|
||||
<JotformIframe config={formAIframe} heading={serviceCopy.heading} variant="drawer" deferUntilActivated hideHeading />
|
||||
</div>
|
||||
: <div class="service-inquiry-form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form="service" data-mock-result={mockResult} data-mock-success="Demo only: the local fixture completed without sending anything." data-mock-failed="Demo only: the local fixture simulated a failed submission." data-mock-unavailable="Demo only: the local fixture simulated an unavailable service.">
|
||||
|
||||
@@ -36,7 +36,38 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
[hidden] { display: none; }
|
||||
.employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; }
|
||||
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
.employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; }
|
||||
.employee-portal__content :global(td), .employee-portal__content :global(th) { padding: .5rem; }
|
||||
.employee-portal__content { line-height: 1.65; }
|
||||
.employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
color: var(--color-primary); font-family: var(--font-heading); line-height: 1.25;
|
||||
margin-block: 2rem 1rem;
|
||||
}
|
||||
.employee-portal__content :global(:is(h1, h2)) { font-size: clamp(1.8rem, 3vw, 2.5rem); }
|
||||
.employee-portal__content :global(h3) { font-size: clamp(1.4rem, 2vw, 1.8rem); }
|
||||
.employee-portal__content :global(h4) { font-size: 1.25rem; }
|
||||
.employee-portal__content :global(p) { margin-block: .75rem 1.25rem; }
|
||||
.employee-portal__content :global(:is(ul, ol)) { padding-inline-start: 1.5rem; margin-block: 1rem 1.5rem; }
|
||||
.employee-portal__content :global(li) { margin-block: .5rem; }
|
||||
.employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; }
|
||||
.employee-portal__content :global(hr) { border: 0; border-top: 1px solid var(--color-border); margin-block: 2rem; }
|
||||
.employee-portal__content :global(a.portal-resource-button) {
|
||||
display: inline-block; background: var(--color-accent); border: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm); color: var(--color-primary); font-family: var(--font-body);
|
||||
font-size: 1rem; font-weight: 400; line-height: 1.5; padding: .65rem 1.2rem;
|
||||
margin-block: .35rem; max-width: 100%; text-decoration: none;
|
||||
}
|
||||
.employee-portal__content :global(a.portal-resource-button:is(:hover, :focus-visible)) {
|
||||
background: transparent; border-color: var(--color-accent-strong);
|
||||
}
|
||||
.employee-portal__content :global(a.portal-contact-link) {
|
||||
/* Dark orange preserves the source accent with readable contrast on white. */
|
||||
color: #a94600; text-underline-offset: .2em;
|
||||
}
|
||||
.employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; margin-block: 1.5rem; }
|
||||
.employee-portal__content :global(td), .employee-portal__content :global(th) {
|
||||
padding: .75rem; border-bottom: 1px solid var(--color-border); text-align: start; vertical-align: top;
|
||||
}
|
||||
.employee-portal__content :global(th) { background: var(--color-tint); color: var(--color-primary); }
|
||||
.employee-portal__content :global(dt) { font-weight: 600; margin-top: 1rem; }
|
||||
.employee-portal__content :global(dd) { margin-inline-start: 1rem; }
|
||||
@media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
||||
import { portalLinkPresentation } from '../utils/portal-presentation';
|
||||
|
||||
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
|
||||
* embedded forms and styles require owner review and are not loaded on unlock. */
|
||||
@@ -21,9 +22,21 @@ export function safePortalContent(html: string): DocumentFragment {
|
||||
element.setAttribute('rel', 'noopener noreferrer');
|
||||
element.setAttribute('referrerpolicy', 'no-referrer');
|
||||
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
|
||||
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol);
|
||||
if (presentation) element.className = presentation;
|
||||
}
|
||||
} catch { /* Invalid destination remains visible text without navigation. */ }
|
||||
}
|
||||
// Preserve directory semantics without retaining arbitrary legacy attributes.
|
||||
if (source.tagName === 'TH' && ['row', 'col', 'rowgroup', 'colgroup'].includes(source.getAttribute('scope') ?? '')) {
|
||||
element.setAttribute('scope', source.getAttribute('scope')!);
|
||||
}
|
||||
if (['TD', 'TH'].includes(source.tagName)) {
|
||||
for (const attr of ['colspan', 'rowspan']) {
|
||||
const value = source.getAttribute(attr);
|
||||
if (value && /^[1-9]\d?$/.test(value)) element.setAttribute(attr, value);
|
||||
}
|
||||
}
|
||||
for (const child of source.childNodes) copy(child, element);
|
||||
target.appendChild(element);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
|
||||
import { isFormACompletion } from '../utils/jotform-completion';
|
||||
|
||||
let disposeAll: (() => void) | undefined;
|
||||
// Memory preference also covers storage-denied browsing during this document lifetime.
|
||||
@@ -23,6 +24,8 @@ export function initializeServiceInquiryDrawers() {
|
||||
.filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service);
|
||||
let timer: number | undefined;
|
||||
let closeTimer: number | undefined;
|
||||
let completionTimer: number | undefined;
|
||||
const acknowledgment = dialog.querySelector<HTMLElement>('[data-service-inquiry-completed]');
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
let trigger: HTMLElement | null = null;
|
||||
@@ -47,6 +50,11 @@ export function initializeServiceInquiryDrawers() {
|
||||
if (document.querySelector('dialog[open]')) return;
|
||||
autoConsumed = true;
|
||||
trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
if (dialog.dataset.submissionCompleted === 'true') {
|
||||
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-reset'));
|
||||
delete dialog.dataset.submissionCompleted;
|
||||
if (acknowledgment) acknowledgment.hidden = true;
|
||||
}
|
||||
dialog.showModal();
|
||||
document.documentElement.classList.add('service-inquiry-open');
|
||||
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
|
||||
@@ -59,7 +67,8 @@ export function initializeServiceInquiryDrawers() {
|
||||
const dismiss = () => {
|
||||
if (!dialog.open || closing) return;
|
||||
closing = true;
|
||||
remember();
|
||||
clearTimeout(completionTimer);
|
||||
if (dialog.dataset.submissionCompleted !== 'true') remember();
|
||||
stopAuto();
|
||||
dialog.classList.remove('is-open');
|
||||
const finish = () => { dialog.close(); closing = false; };
|
||||
@@ -74,6 +83,7 @@ export function initializeServiceInquiryDrawers() {
|
||||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
|
||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
|
||||
dialog.addEventListener('close', () => {
|
||||
clearTimeout(completionTimer);
|
||||
stopAuto();
|
||||
dialog.classList.remove('is-open');
|
||||
document.documentElement.classList.remove('service-inquiry-open');
|
||||
@@ -81,6 +91,20 @@ export function initializeServiceInquiryDrawers() {
|
||||
trigger = null;
|
||||
}, { signal });
|
||||
if (authorized) {
|
||||
addEventListener('message', (event) => {
|
||||
const container = dialog.querySelector<HTMLElement>('[data-jotform-form-a]');
|
||||
const frame = container?.querySelector<HTMLIFrameElement>('[data-jotform-iframe-frame]');
|
||||
const active = dialog.open && !closing && dialog.dataset.submissionCompleted !== 'true' &&
|
||||
container?.dataset.formContext === 'consultation' && container.dataset.jotformIframeActivated === 'true';
|
||||
if (!isFormACompletion(event, frame?.contentWindow, active, frame?.dataset.jotformIframeUrl)) return;
|
||||
dialog.dataset.submissionCompleted = 'true';
|
||||
remember();
|
||||
if (acknowledgment) {
|
||||
acknowledgment.hidden = false;
|
||||
acknowledgment.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
||||
}
|
||||
completionTimer = window.setTimeout(dismiss, 5000);
|
||||
}, { signal });
|
||||
for (const anchor of triggers) {
|
||||
anchor.setAttribute('aria-haspopup', 'dialog');
|
||||
anchor.setAttribute('aria-controls', dialog.id);
|
||||
@@ -103,7 +127,7 @@ export function initializeServiceInquiryDrawers() {
|
||||
}
|
||||
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
|
||||
disposers.push(() => {
|
||||
stopAuto(); clearTimeout(closeTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
|
||||
stopAuto(); clearTimeout(closeTimer); clearTimeout(completionTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
|
||||
controller.abort(); dialog.classList.remove('is-open');
|
||||
if (dialog.open) dialog.close();
|
||||
for (const anchor of triggers) { anchor.removeAttribute('aria-controls'); anchor.removeAttribute('aria-haspopup'); }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Only the provider's completion envelope is used. Never retain/log answers. */
|
||||
export function isFormACompletion(event: { origin: string; source: unknown; data: unknown },
|
||||
frameWindow: unknown, active: boolean, approvedUrl: string | undefined): boolean {
|
||||
if (!active || !frameWindow || event.source !== frameWindow || !approvedUrl) return false;
|
||||
// This URL comes only from the server-rendered, gated route configuration,
|
||||
// never from the message, current child location, query string or referrer.
|
||||
let expected: URL;
|
||||
try { expected = new URL(approvedUrl); } catch { return false; }
|
||||
if (expected.protocol !== 'https:' || event.origin !== expected.origin) return false;
|
||||
let data = event.data;
|
||||
if (typeof data === 'string') {
|
||||
if (data.length > 4096) return false;
|
||||
try { data = JSON.parse(data); } catch { return false; }
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
|
||||
const message = data as Record<string, unknown>;
|
||||
return message.action === 'submission-completed' && message.formID === expected.pathname.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Translate a narrow presentation vocabulary; never copy arbitrary classes/styles. */
|
||||
export function portalLinkPresentation(classes: string[], background: string, protocol: string): string | undefined {
|
||||
if (protocol === 'mailto:' || protocol === 'tel:') return 'portal-contact-link';
|
||||
if (classes.some((name) => ['hs-button', 'content-button', 'portal-resource-button'].includes(name)) ||
|
||||
['rgb(255, 189, 89)', 'rgb(255, 145, 77)', '#ffbd59', '#ff914d'].includes(background.toLowerCase())) {
|
||||
return 'portal-resource-button';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in new issue
Block a user