import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
/**
* TurboModal Stimulus Controller
*
* Handles explicit Turbo Stream form submission while coordinating Bootstrap
* modal transitions, visible feedback, and focus restoration after page updates.
*
* Features:
* - Modal closing before successful Turbo Stream rendering
* - Bootstrap modal integration
* - Turbo Form submission handling
* - Prevents modal from interfering with page updates
*
* Usage:
* <form data-controller="turbo-modal"
* data-action="submit->turbo-modal#submitAsTurboStream turbo:submit-start->turbo-modal#closeModalBeforeSubmit"
* data-turbo="true">
* <!-- Form contents -->
* </form>
*
* Successful stream responses close the modal before updating the page. Failed
* requests leave it open so the user can review feedback and retry.
*/
class TurboModal extends Controller {
static values = {
successMessage: String,
errorMessage: String,
}
initialize() {
this.modalTrigger = null;
this.captureModalTriggerListener = this.captureModalTrigger.bind(this);
this.modalHideCleanup = null;
}
/**
* Initialize - log when controller connects
*/
connect() {
console.log('TurboModal controller connected');
this.findModalElement()?.addEventListener(
'show.bs.modal',
this.captureModalTriggerListener,
);
}
disconnect() {
this.findModalElement()?.removeEventListener(
'show.bs.modal',
this.captureModalTriggerListener,
);
this.modalHideCleanup?.();
this.modalHideCleanup = null;
}
/** Remember the control that opened the modal for focus restoration. */
captureModalTrigger(event) {
this.modalTrigger = event.relatedTarget instanceof HTMLElement
? event.relatedTarget
: null;
}
/**
* Close the modal before form submission starts
*
* @param {Event} event - The turbo:submit-start event
*/
closeModalBeforeSubmit(event) {
console.log('turbo:submit-start - closing modal before submission');
this.closeModal();
}
/**
* Submit a modal form as a Turbo Stream request.
*
* Turbo Drive is disabled globally, so modal forms that should update the
* current grid need an explicit stream fetch instead of browser navigation.
*
* @param {SubmitEvent} event - The submit event
*/
async submitAsTurboStream(event) {
if (!(this.element instanceof HTMLFormElement)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
this.syncPageContext();
this.clearFailure();
this.setSubmitting(true);
try {
const csrfToken = this.element.querySelector('input[name="_csrfToken"]')?.value
|| document.querySelector('meta[name="csrf-token"]')?.content;
const headers = {
'Accept': 'text/vnd.turbo-stream.html',
'X-Requested-With': 'XMLHttpRequest',
};
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch(this.element.action, {
method: (this.element.method || 'POST').toUpperCase(),
body: new FormData(this.element),
headers,
credentials: 'same-origin',
});
const body = await response.text();
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('text/vnd.turbo-stream.html') || body.includes('<turbo-stream')) {
const focusPlan = response.ok ? this.createStreamFocusPlan(body) : null;
const streamIncludesLiveFeedback = this.streamIncludesLiveFeedback(body);
let streamRendered = true;
if (response.ok) {
await this.closeModalAndWait();
}
try {
this.renderTurboStream(body);
} catch (renderError) {
if (!response.ok) {
throw renderError;
}
console.error('Unable to render successful modal response:', renderError);
streamRendered = false;
if (!this.showFallbackSuccess()) {
this.announceSuccess();
}
}
if (response.ok) {
await this.restoreFocusAfterStream(focusPlan);
if (streamRendered && !streamIncludesLiveFeedback) {
this.announceSuccess();
}
} else if (!streamIncludesLiveFeedback && !this.showFallbackFailure()) {
this.announceFailure();
}
return;
}
if (response.redirected) {
window.location.assign(response.url);
return;
}
const frame = this.element.closest('turbo-frame');
if (frame && body !== '') {
frame.innerHTML = body;
}
if (!response.ok) {
if (!this.showFallbackFailure()) {
this.announceFailure();
}
}
} catch (error) {
console.error('Unable to submit modal form:', error);
if (!this.showFallbackFailure()) {
this.announceFailure();
}
} finally {
this.setSubmitting(false);
}
}
/** Sync hidden page context to the visible browser URL before posting. */
syncPageContext() {
const input = this.element.querySelector('input[name="page_context_url"]');
if (input && input.dataset.pageContextStatic !== 'true') {
input.value = window.location.pathname + window.location.search;
}
}
/**
* @param {string} streamHtml Turbo stream HTML
*/
renderTurboStream(streamHtml) {
Turbo.renderStreamMessage(streamHtml);
}
/** Return true when the stream already provides an accessible live-region message. */
streamIncludesLiveFeedback(streamHtml) {
const fragment = document.createElement('template');
fragment.innerHTML = streamHtml;
const liveRegionSelector = '[role="alert"], [role="status"], [aria-live]';
if (fragment.content.querySelector(liveRegionSelector)) {
return true;
}
return Array.from(fragment.content.querySelectorAll('template')).some(
(template) => template.content.querySelector(liveRegionSelector) !== null,
);
}
/**
* Resolve the Bootstrap modal for this form.
*
* Officers (and similar) wrap the modal markup inside the form; others nest the
* form inside the modal. Support both DOM shapes.
*/
findModalElement() {
if (!(this.element instanceof HTMLFormElement)) {
return null;
}
return this.element.querySelector('.modal') ?? this.element.closest('.modal');
}
/** Return the Bootstrap instance for a modal, creating it when supported. */
modalInstance(modal) {
if (!modal || !window.bootstrap?.Modal) {
return null;
}
const Modal = window.bootstrap.Modal;
const existingInstance = Modal.getInstance(modal);
if (existingInstance) {
return existingInstance;
}
return typeof Modal.getOrCreateInstance === 'function'
? Modal.getOrCreateInstance(modal)
: null;
}
/** Hide the containing Bootstrap modal if one exists. */
closeModal() {
const modal = this.findModalElement();
const modalInstance = this.modalInstance(modal);
if (!modal || !modalInstance) {
return;
}
modalInstance.hide();
this.dismissModalBackdrop();
}
/** Hide a visible modal and resolve only after Bootstrap finishes its transition. */
async closeModalAndWait() {
const modal = this.findModalElement();
const modalInstance = this.modalInstance(modal);
if (!modal || !modalInstance) {
return;
}
const isVisible = modal.classList.contains('show')
&& modal.getAttribute('aria-hidden') !== 'true';
if (!isVisible) {
modalInstance.hide();
this.dismissModalBackdrop();
return;
}
await new Promise((resolve, reject) => {
let timeoutId;
const cleanup = () => {
modal.removeEventListener('hidden.bs.modal', finish);
window.clearTimeout(timeoutId);
if (this.modalHideCleanup === finish) {
this.modalHideCleanup = null;
}
};
const finish = () => {
cleanup();
resolve();
};
modal.addEventListener('hidden.bs.modal', finish, { once: true });
timeoutId = window.setTimeout(finish, 750);
this.modalHideCleanup = finish;
try {
modalInstance.hide();
} catch (error) {
cleanup();
reject(error);
}
});
this.dismissModalBackdrop();
}
/**
* Capture enough of a row-changing Turbo Stream to select a logical focus target afterward.
*
* @param {string} streamHtml Turbo Stream response body.
* @returns {object|null} Focus restoration plan.
*/
createStreamFocusPlan(streamHtml) {
const template = document.createElement('template');
template.innerHTML = streamHtml;
const stream = Array.from(template.content.querySelectorAll('turbo-stream[target]'))
.find((candidate) => candidate.getAttribute('target') !== 'flash-messages');
const targetId = stream?.getAttribute('target');
if (!targetId) {
return this.modalTrigger ? { trigger: this.modalTrigger } : null;
}
const currentTarget = document.getElementById(targetId);
return {
targetId,
nextTarget: this.findFocusable(currentTarget?.nextElementSibling)
?? currentTarget?.nextElementSibling,
previousTarget: this.findFocusable(currentTarget?.previousElementSibling)
?? currentTarget?.previousElementSibling,
container: currentTarget?.closest(
'turbo-frame, [data-controller~="grid-view"], table',
),
trigger: this.modalTrigger,
};
}
/** Find the preferred actionable descendant of a potential focus target. */
findFocusable(root) {
if (!(root instanceof HTMLElement)) {
return null;
}
const selector = [
'.edit-btn:not([disabled])',
'[data-bs-toggle="modal"]:not([disabled])',
'button:not([disabled])',
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
return root.matches(selector) ? root : root.querySelector(selector);
}
/** Focus the replacement action or the nearest surviving grid context. */
async restoreFocusAfterStream(focusPlan) {
if (!focusPlan) {
return;
}
await this.waitForRenderFrame();
const replacement = focusPlan.targetId
? document.getElementById(focusPlan.targetId)
: null;
const target = this.findFocusable(replacement)
?? replacement
?? this.connectedElement(focusPlan.nextTarget)
?? this.connectedElement(focusPlan.previousTarget)
?? this.connectedElement(focusPlan.trigger)
?? this.connectedElement(focusPlan.container);
if (!(target instanceof HTMLElement)) {
return;
}
const focusTarget = this.findFocusable(target) ?? target;
if (focusTarget === target && !target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1');
}
focusTarget.focus();
}
/** Wait one bounded rendering step for Turbo custom elements to update the DOM. */
async waitForRenderFrame() {
await new Promise((resolve) => {
let settled = false;
let frameId = null;
const finish = () => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
if (frameId !== null && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(frameId);
}
resolve();
};
const timeoutId = window.setTimeout(finish, 50);
if (typeof window.requestAnimationFrame === 'function') {
frameId = window.requestAnimationFrame(finish);
} else {
window.setTimeout(finish, 0);
}
});
}
/** Return an element only while it remains connected to the current document. */
connectedElement(element) {
return element instanceof HTMLElement && element.isConnected ? element : null;
}
/** Announce successful completion after the modal starts closing. */
announceSuccess() {
window.KMP_accessibility?.announce?.(this.successMessage());
}
/** Announce a failed submission after any server-provided stream is rendered. */
announceFailure() {
window.KMP_accessibility?.announce?.(
this.failureMessage(),
{ assertive: true },
);
}
/** Return the configured failure copy for visible and announced feedback. */
failureMessage() {
return this.errorMessageValue
|| this.element.dataset.turboModalErrorMessageValue
|| 'Unable to save. Please try again.';
}
/** Return the configured success copy for visible and announced feedback. */
successMessage() {
return this.successMessageValue
|| this.element.dataset.turboModalSuccessMessageValue
|| 'Saved successfully.';
}
/** Show visible feedback if Turbo cannot apply an otherwise successful stream. */
showFallbackSuccess() {
const container = document.getElementById('flash-messages');
if (!container) {
return false;
}
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.setAttribute('role', 'status');
alert.append(document.createTextNode(this.successMessage()));
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'alert');
closeButton.setAttribute('aria-label', 'Close');
alert.append(closeButton);
container.replaceChildren(alert);
return true;
}
/** Remove stale in-modal failure feedback before retrying a submission. */
clearFailure() {
const container = this.findModalElement()
?.querySelector('[data-turbo-modal-feedback]')
?? this.element.querySelector('[data-turbo-modal-feedback]');
if (!container) {
return;
}
container.replaceChildren();
container.classList.add('d-none');
}
/** Resolve or create the visible feedback container inside the active modal. */
failureContainer() {
let container = this.findModalElement()
?.querySelector('[data-turbo-modal-feedback]')
?? this.element.querySelector('[data-turbo-modal-feedback]');
if (container) {
return container;
}
container = document.createElement('div');
container.setAttribute('data-turbo-modal-feedback', '');
container.className = 'mb-3';
const modalBody = this.findModalElement()?.querySelector('.modal-body');
(modalBody ?? this.element).prepend(container);
return container;
}
/** Show a visible retryable error while keeping the modal and entered values open. */
showFallbackFailure() {
if (!this.element.isConnected) {
return false;
}
const container = this.failureContainer();
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show mb-0';
alert.setAttribute('role', 'alert');
alert.append(document.createTextNode(this.failureMessage()));
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'alert');
closeButton.setAttribute('aria-label', 'Close');
alert.append(closeButton);
container.replaceChildren(alert);
container.classList.remove('d-none');
return container.isConnected;
}
/** Prevent duplicate submits while the Turbo Stream request is in-flight. */
setSubmitting(isSubmitting) {
const controls = Array.from(
this.element.querySelectorAll('button[type="submit"], input[type="submit"]'),
);
if (this.element.id) {
const escapedId = window.CSS?.escape
? window.CSS.escape(this.element.id)
: this.element.id.replace(/(["\\])/g, '\\$1');
controls.push(...document.querySelectorAll(
`button[type="submit"][form="${escapedId}"], input[type="submit"][form="${escapedId}"]`,
));
}
new Set(controls).forEach((control) => {
control.disabled = isSubmitting;
control.setAttribute('aria-busy', isSubmitting ? 'true' : 'false');
});
}
/** Remove stray backdrops when Bootstrap did not fully tear down the modal. */
dismissModalBackdrop() {
document.body.classList.remove('modal-open');
document.body.style.removeProperty('padding-right');
document.querySelectorAll('.modal-backdrop').forEach((backdrop) => backdrop.remove());
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["turbo-modal"] = TurboModal;