<script>
'use strict';
(function() {
const modals = [];
const excludedFromFocusTrapping = new Set();
function trapFocusInNextModalWithOverlay() {
for (let idx = modals.length - 1; idx >= 0; idx--) {
const nextOnStack = modals[idx];
const nextDialogElement = nextOnStack.instance.$refs[nextOnStack.name];
if (!isOverlayDisabled(nextDialogElement)) {
hyva.trapFocus(nextDialogElement);
break;
}
}
}
function focusables(dialogElement) {
const selector = 'button, [href], input, textarea, select, details, [tabindex]:not([tabindex="-1"])';
return Array.from(dialogElement.querySelectorAll(selector))
.filter(el => !el.hasAttribute('disabled'));
}
function firstVisible(elements) {
const a = Array.from(elements);
for (let i = 0; i < a.length; i++) {
if (a[i].offsetWidth || a[i].offsetHeight || a[i].getClientRects().length) return a[i];
}
return null;
}
function isInViewport(element) {
const rect = element && element.getBoundingClientRect();
return rect &&
rect.top >= 0 &&
rect.left >= 0 &&
rect.right <= window.innerWidth &&
rect.bottom <= window.innerHeight;
}
function setFocusAfterTransition(dialogElement, duration) {
/*
* If the dialog contains an x-focus-first element that is not within a nested dialog, set focus
* immediately from the trapFocus default, to avoid the switch after the transition duration.
*/
const nested = Array.from(dialogElement.querySelectorAll('[role="dialog"]'));
const candidates = Array.from(dialogElement.querySelectorAll('[x-focus-first]'));
next: for (let candidate of candidates) {
for (let child of nested) {
if (child.contains(candidate)) continue next;
}
setTimeout(() => candidate.focus(), 50);
break;
}
window.setTimeout(() => {
const focusElement = firstVisible(dialogElement.querySelectorAll('[x-focus-first]')) ||
focusables(dialogElement)[0] ||
null;
focusElement && isInViewport(focusElement) && focusElement.focus();
}, Math.max(1, duration));
}
function determineTrigger($refs, dialog, trigger) {
/* if show() was called without arguments use the event target as open trigger */
if (typeof trigger === 'undefined' && typeof dialog === 'object' && dialog.target instanceof HTMLElement) {
return dialog.target;
}
/* if show('name', $event) was called with the event as the second argument use the event target as trigger */
if (typeof dialog === 'string' && typeof trigger === 'object' && trigger.target instanceof HTMLElement) {
return trigger.target;
}
/* if show('name', 'trigger') was called with the ref name pr selector of the trigger element */
if (typeof trigger === 'string') {
try {
return $refs[trigger] || document.querySelector(trigger)
} catch (e) {
/* Intentionally left empty because we don't know if trigger is intended as a valid selector string */
}
}
/* if show('name', document.querySelector('...')) was called, use that as the trigger */
if (trigger instanceof Element) {
return trigger;
}
/* unknown trigger - no focus will be set when the dialog is hidden */
return null;
}
function isOverlayDisabled(dialog) {
return dialog && dialog.hasAttribute('x-no-overlay')
}
function areRemainingModalsWithoutOverlay(modals) {
const overflowDisabled = modals.map(modal => modal.instance.$refs[modal.name]).filter(isOverlayDisabled);
return overflowDisabled.length === modals.length;
}
window.hyva.modal = function(options) {
const config = Object.assign({
dialog: 'dialog',
duration: 300,
/* ms before allowing subsequent hiding of modals for nested modals (see transition duration) */
transitionEnter: 'transition ease-out duration-300',
transitionEnterStart: 'opacity-0',
transitionEnterEnd: 'opacity-100',
transitionLeave: 'transition ease-in duration-300',
transitionLeaveStart: 'opacity-100',
transitionLeaveEnd: 'opacity-0',
}, options);
let lastHide = 0;
return {
opened: {},
show(dialog, trigger) {
const focusTargetAfterHide = determineTrigger(this.$refs, dialog, trigger);
const name = typeof dialog === 'string' ? dialog : config.dialog;
const dialogElement = this.$refs[name];
if (!dialogElement) {
console.error(`Use $modal->getShowJs() in the open trigger, or specify a custom name with\n$modal->withDialogRefName("my-name") and use show("my-name", $event).`);
return;
}
const useOverlay = !dialogElement.hasAttribute('x-no-overlay');
dialogElement.scrollTop = 0;
/* Prevent adding the same modal on the stack twice */
if (this.opened[name]) {
return;
}
if (focusTargetAfterHide) {
focusTargetAfterHide.setAttribute('aria-expanded', 'true');
}
this.opened[name] = true;
useOverlay && this.$nextTick(() => hyva.trapFocus(dialogElement));
setFocusAfterTransition(dialogElement, config.duration);
const frame = {
name,
instance: this,
focusTarget: focusTargetAfterHide,
time: Date.now()
};
modals.push(frame);
if (useOverlay) {
document.body.classList.add('overflow-hidden');
}
return new Promise(resolve => frame.resolve = resolve);
},
cancel() {
this.hide(false);
},
ok() {
this.hide(true);
},
hide(value) {
// Guard against Escape being pressed multiple times before a transition is finished, otherwise
// this function will pop further dialogs from the stack but the display will not update.
if (Date.now() - lastHide < config.duration) {
return;
}
lastHide = Date.now();
const modal = modals.pop() || {};
const name = modal.name;
this.opened[name] = false;
hyva.releaseFocus(modal.instance.$refs[modal.name])
trapFocusInNextModalWithOverlay();
const nextFocusAfterHide = modal.focusTarget;
nextFocusAfterHide && setTimeout(() => {
nextFocusAfterHide.setAttribute('aria-expanded', 'false');
nextFocusAfterHide.focus()
}, config.duration);
if (modals.length === 0 || areRemainingModalsWithoutOverlay(modals)) {
document.body.classList.remove('overflow-hidden');
}
modal.resolve(value);
},
overlay(dialog) {
const name = typeof dialog === 'string' ? dialog : config.dialog;
return {
['x-show']() {
return this.opened[name]
},
['x-transition:enter']: config.transitionEnter,
['x-transition:enter-start']: config.transitionEnterStart,
['x-transition:enter-end']: config.transitionEnterEnd,
['x-transition:leave']: config.transitionLeave,
['x-transition:leave-start']: config.transitionLeaveStart,
['x-transition:leave-end']: config.transitionLeaveEnd,
['@hyva-modal-show.window'](event) {
event.detail && event.detail.dialog === name && this.show(name, event.detail.focusAfterHide)
}
};
}
};
}
window.hyva.modal.peek = () => modals.length > 0 && modals[modals.length - 1]
window.hyva.modal.pop = function() {
if (modals.length > 0) {
const modal = modals[modals.length - 1];
modal.instance.hide();
}
}
window.hyva.modal.excludeSelectorsFromFocusTrap = function(selectors) {
typeof selectors === 'string' || selectors instanceof String ?
excludedFromFocusTrapping.add(selectors) :
selectors.map(selector => excludedFromFocusTrapping.add(selector));
}
window.hyva.modal.eventListeners = {
keydown: event => {
if (event.key === 'Escape') {
window.hyva.modal.pop();
}
},
/* generic modal @click.outside handler */
click: event => {
if (modals.length > 0) {
const modal = modals[modals.length - 1];
const dialog = modal.instance.$refs[modal.name];
if (modal.time + 50 < Date.now() && // if last click processing is more than 50ms ago
!isOverlayDisabled(dialog) && // if dialog has overlay
!dialog.contains(event.target)) { // if click is outside of dialog
modal.instance.hide();
}
}
}
};
document.addEventListener('keydown', window.hyva.modal.eventListeners.keydown);
document.addEventListener('click', window.hyva.modal.eventListeners.click);
})();
</script>
<div x-data="hyva.modal()">
<button type="button" class="btn justify-center rounded-md py-3 px-6 text-base shadow-none hover:shadow-lg active:shadow disabled:shadow-none transition bg-white text-blue-700 border border-blue-400 hover:bg-white focus:ring-blue-200 disabled:bg-white disabled:border-slate-200 disabled:text-slate-600 disabled:opacity-70" @click="show('modal', $event)">
Show Modal
</button>
<div x-cloak x-bind="overlay('modal')" x-spread="overlay('modal')" class="fixed inset-0 bg-black bg-opacity-50 z-50">
<div class="fixed flex justify-center items-center text-left z-40'">
<div x-ref="modal" role="dialog" aria-modal="true" class="inline-block bg-white shadow-xl rounded-lg p-10 max-h-screen overflow-auto overscroll-y-contain'">
CONTENT HERE
</div>
</div>
</div>
</div>
<script>
'use strict';
(function () {
const modals = [];
const excludedFromFocusTrapping = new Set();
function trapFocusInNextModalWithOverlay() {
for (let idx = modals.length -1; idx >= 0; idx--) {
const nextOnStack = modals[idx];
const nextDialogElement = nextOnStack.instance.$refs[nextOnStack.name];
if (! isOverlayDisabled(nextDialogElement)) {
hyva.trapFocus(nextDialogElement);
break;
}
}
}
function focusables(dialogElement) {
const selector = 'button, [href], input, textarea, select, details, [tabindex]:not([tabindex="-1"])';
return Array.from(dialogElement.querySelectorAll(selector))
.filter(el => !el.hasAttribute('disabled'));
}
function firstVisible(elements) {
const a = Array.from(elements);
for (let i = 0; i < a.length; i++) {
if (a[i].offsetWidth || a[i].offsetHeight || a[i].getClientRects().length) return a[i];
}
return null;
}
function isInViewport(element) {
const rect = element && element.getBoundingClientRect();
return rect &&
rect.top >= 0 &&
rect.left >= 0 &&
rect.right <= window.innerWidth &&
rect.bottom <= window.innerHeight;
}
function setFocusAfterTransition(dialogElement, duration) {
/*
* If the dialog contains an x-focus-first element that is not within a nested dialog, set focus
* immediately from the trapFocus default, to avoid the switch after the transition duration.
*/
const nested = Array.from(dialogElement.querySelectorAll('[role="dialog"]'));
const candidates = Array.from(dialogElement.querySelectorAll('[x-focus-first]'));
next: for (let candidate of candidates) {
for (let child of nested) {
if (child.contains(candidate)) continue next;
}
setTimeout(() => candidate.focus(), 50);
break;
}
window.setTimeout(() => {
const focusElement = firstVisible(dialogElement.querySelectorAll('[x-focus-first]')) ||
focusables(dialogElement)[0] ||
null;
focusElement && isInViewport(focusElement) && focusElement.focus();
}, Math.max(1, duration));
}
function determineTrigger($refs, dialog, trigger) {
/* if show() was called without arguments use the event target as open trigger */
if (typeof trigger === 'undefined' && typeof dialog === 'object' && dialog.target instanceof HTMLElement) {
return dialog.target;
}
/* if show('name', $event) was called with the event as the second argument use the event target as trigger */
if (typeof dialog === 'string' && typeof trigger === 'object' && trigger.target instanceof HTMLElement) {
return trigger.target;
}
/* if show('name', 'trigger') was called with the ref name pr selector of the trigger element */
if (typeof trigger === 'string') {
try {
return $refs[trigger] || document.querySelector(trigger)
} catch (e) {
/* Intentionally left empty because we don't know if trigger is intended as a valid selector string */
}
}
/* if show('name', document.querySelector('...')) was called, use that as the trigger */
if (trigger instanceof Element) {
return trigger;
}
/* unknown trigger - no focus will be set when the dialog is hidden */
return null;
}
function isOverlayDisabled(dialog) {
return dialog && dialog.hasAttribute('x-no-overlay')
}
function areRemainingModalsWithoutOverlay(modals)
{
const overflowDisabled = modals.map(modal => modal.instance.$refs[modal.name]).filter(isOverlayDisabled);
return overflowDisabled.length === modals.length;
}
window.hyva.modal = function(options) {
const config = Object.assign({
dialog: 'dialog',
duration: 300, /* ms before allowing subsequent hiding of modals for nested modals (see transition duration) */
transitionEnter: 'transition ease-out duration-300',
transitionEnterStart: 'opacity-0',
transitionEnterEnd: 'opacity-100',
transitionLeave: 'transition ease-in duration-300',
transitionLeaveStart: 'opacity-100',
transitionLeaveEnd: 'opacity-0',
}, options);
let lastHide = 0;
return {
opened: {},
show(dialog, trigger) {
const focusTargetAfterHide = determineTrigger(this.$refs, dialog, trigger);
const name = typeof dialog === 'string' ? dialog : config.dialog;
const dialogElement = this.$refs[name];
if (! dialogElement) {
console.error(`Use $modal->getShowJs() in the open trigger, or specify a custom name with\n$modal->withDialogRefName("my-name") and use show("my-name", $event).`);
return;
}
const useOverlay = ! dialogElement.hasAttribute('x-no-overlay');
dialogElement.scrollTop = 0;
/* Prevent adding the same modal on the stack twice */
if (this.opened[name]) {
return;
}
if (focusTargetAfterHide) {
focusTargetAfterHide.setAttribute('aria-expanded', 'true');
}
this.opened[name] = true;
useOverlay && this.$nextTick(() => hyva.trapFocus(dialogElement));
setFocusAfterTransition(dialogElement, config.duration);
const frame = {name, instance: this, focusTarget: focusTargetAfterHide, time: Date.now()};
modals.push(frame);
if (useOverlay) {
document.body.classList.add('overflow-hidden');
}
return new Promise(resolve => frame.resolve = resolve);
},
cancel() {
this.hide(false);
},
ok() {
this.hide(true);
},
hide(value) {
// Guard against Escape being pressed multiple times before a transition is finished, otherwise
// this function will pop further dialogs from the stack but the display will not update.
if (Date.now() - lastHide < config.duration) {
return;
}
lastHide = Date.now();
const modal = modals.pop() || {};
const name = modal.name;
this.opened[name] = false;
hyva.releaseFocus(modal.instance.$refs[modal.name])
trapFocusInNextModalWithOverlay();
const nextFocusAfterHide = modal.focusTarget;
nextFocusAfterHide && setTimeout(() => {
nextFocusAfterHide.setAttribute('aria-expanded', 'false');
nextFocusAfterHide.focus()
}, config.duration);
if (modals.length === 0 || areRemainingModalsWithoutOverlay(modals)) {
document.body.classList.remove('overflow-hidden');
}
modal.resolve(value);
},
overlay(dialog) {
const name = typeof dialog === 'string' ? dialog : config.dialog;
return {
['x-show']() {
return this.opened[name]
},
['x-transition:enter']: config.transitionEnter,
['x-transition:enter-start']: config.transitionEnterStart,
['x-transition:enter-end']: config.transitionEnterEnd,
['x-transition:leave']: config.transitionLeave,
['x-transition:leave-start']: config.transitionLeaveStart,
['x-transition:leave-end']: config.transitionLeaveEnd,
['@hyva-modal-show.window'](event) {
event.detail && event.detail.dialog === name && this.show(name, event.detail.focusAfterHide)
}
};
}
};
}
window.hyva.modal.peek = () => modals.length > 0 && modals[modals.length -1]
window.hyva.modal.pop = function () {
if (modals.length > 0) {
const modal = modals[modals.length -1];
modal.instance.hide();
}
}
window.hyva.modal.excludeSelectorsFromFocusTrap = function (selectors) {
typeof selectors === 'string' || selectors instanceof String
? excludedFromFocusTrapping.add(selectors)
: selectors.map(selector => excludedFromFocusTrapping.add(selector));
}
window.hyva.modal.eventListeners = {
keydown: event => {
if (event.key === 'Escape') {
window.hyva.modal.pop();
}
},
/* generic modal @click.outside handler */
click: event => {
if (modals.length > 0) {
const modal = modals[modals.length -1];
const dialog = modal.instance.$refs[modal.name];
if (modal.time + 50 < Date.now() && // if last click processing is more than 50ms ago
! isOverlayDisabled(dialog) && // if dialog has overlay
! dialog.contains(event.target)) { // if click is outside of dialog
modal.instance.hide();
}
}
}
};
document.addEventListener('keydown', window.hyva.modal.eventListeners.keydown);
document.addEventListener('click', window.hyva.modal.eventListeners.click);
})();
</script>
<div x-data="hyva.modal()">
<button type="button"
class="btn justify-center rounded-md py-3 px-6 text-base shadow-none hover:shadow-lg active:shadow disabled:shadow-none transition bg-white text-blue-700 border border-blue-400 hover:bg-white focus:ring-blue-200 disabled:bg-white disabled:border-slate-200 disabled:text-slate-600 disabled:opacity-70"
@click="show('modal', $event)">
Show Modal
</button>
<div x-cloak
x-bind="overlay('modal')"
x-spread="overlay('modal')"
class="fixed inset-0 bg-black bg-opacity-50 z-50">
<div class="fixed flex justify-center items-center text-left z-40'">
<div x-ref="modal" role="dialog" aria-modal="true"
class="inline-block bg-white shadow-xl rounded-lg p-10 max-h-screen overflow-auto overscroll-y-contain'">
CONTENT HERE
</div>
</div>
</div>
</div>
/* No context defined. */
No notes defined.