preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
658 lines (550 loc) • 17.9 kB
text/typescript
/*
* HSOverlay
* @version: 2.5.0
* @author: Preline Labs Ltd.
* @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html)
* Copyright 2024 Preline Labs Ltd.
*/
import {
stringToBoolean,
getClassProperty,
isParentOrElementHidden,
isDirectChild,
dispatch,
afterTransition,
} from '../../utils';
import { IOverlayOptions, IOverlay } from '../overlay/interfaces';
import { ICollectionItem } from '../../interfaces';
import { BREAKPOINTS } from '../../constants';
import HSBasePlugin from '../base-plugin';
class HSOverlay extends HSBasePlugin<{}> implements IOverlay {
private readonly hiddenClass: string | null;
private readonly emulateScrollbarSpace: boolean;
private readonly isClosePrev: boolean;
private readonly backdropClasses: string | null;
private readonly backdropExtraClasses: string | null;
private readonly animationTarget: HTMLElement | null;
private openNextOverlay: boolean;
private autoHide: ReturnType<typeof setTimeout> | null;
private readonly overlayId: string | null;
public overlay: HTMLElement | null;
public initContainer: HTMLElement | null;
public isCloseWhenClickInside: boolean;
public isTabAccessibilityLimited: boolean;
public isLayoutAffect: boolean;
public hasAutofocus: boolean;
public hasAbilityToCloseOnBackdropClick: boolean;
public openedBreakpoint: number | null;
public autoClose: number | null;
public moveOverlayToBody: number | null;
constructor(el: HTMLElement, options?: IOverlayOptions, events?: {}) {
super(el, options, events);
const data = el.getAttribute('data-hs-overlay-options');
const dataOptions: IOverlayOptions = data ? JSON.parse(data) : {};
const concatOptions = {
...dataOptions,
...options,
};
this.hiddenClass = concatOptions?.hiddenClass || 'hidden';
this.emulateScrollbarSpace = concatOptions?.emulateScrollbarSpace || false;
this.isClosePrev = concatOptions?.isClosePrev ?? true;
this.backdropClasses =
concatOptions?.backdropClasses ??
'hs-overlay-backdrop transition duration fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 dark:bg-neutral-900';
this.backdropExtraClasses = concatOptions?.backdropExtraClasses ?? '';
this.moveOverlayToBody = concatOptions?.moveOverlayToBody || null;
this.openNextOverlay = false;
this.autoHide = null;
this.overlayId = this.el.getAttribute('data-hs-overlay');
this.overlay = document.querySelector(this.overlayId);
this.initContainer = this.overlay?.parentElement || null;
if (this.overlay) {
this.isCloseWhenClickInside = stringToBoolean(
getClassProperty(this.overlay, '--close-when-click-inside', 'false') ||
'false',
);
this.isTabAccessibilityLimited = stringToBoolean(
getClassProperty(this.overlay, '--tab-accessibility-limited', 'true') ||
'true',
);
this.isLayoutAffect = stringToBoolean(
getClassProperty(this.overlay, '--is-layout-affect', 'false') ||
'false',
);
this.hasAutofocus = stringToBoolean(
getClassProperty(this.overlay, '--has-autofocus', 'true') || 'true',
);
this.hasAbilityToCloseOnBackdropClick = stringToBoolean(
this.overlay.getAttribute('data-hs-overlay-keyboard') || 'true',
);
const autoCloseBreakpoint = getClassProperty(
this.overlay,
'--auto-close',
);
this.autoClose =
!isNaN(+autoCloseBreakpoint) && isFinite(+autoCloseBreakpoint)
? +autoCloseBreakpoint
: BREAKPOINTS[autoCloseBreakpoint] || null;
const openedBreakpoint = getClassProperty(this.overlay, '--opened');
this.openedBreakpoint =
(!isNaN(+openedBreakpoint) && isFinite(+openedBreakpoint)
? +openedBreakpoint
: BREAKPOINTS[openedBreakpoint]) || null;
}
this.animationTarget =
this?.overlay?.querySelector('.hs-overlay-animation-target') ||
this.overlay;
if (this.overlay) this.init();
}
private init() {
this.createCollection(window.$hsOverlayCollection, this);
if (this.isLayoutAffect && this.openedBreakpoint) {
const instance = HSOverlay.getInstance(this.el, true);
HSOverlay.setOpened(
this.openedBreakpoint,
instance as ICollectionItem<HSOverlay>,
);
}
if (this?.el?.ariaExpanded) {
if (this.overlay.classList.contains('opened'))
this.el.ariaExpanded = 'true';
else this.el.ariaExpanded = 'false';
}
this.el.addEventListener('click', () => {
if (this.overlay.classList.contains('opened')) this.close();
else this.open();
});
this.overlay.addEventListener('click', (evt) => {
if (
(evt.target as HTMLElement).id &&
`#${(evt.target as HTMLElement).id}` === this.overlayId &&
this.isCloseWhenClickInside &&
this.hasAbilityToCloseOnBackdropClick
) {
this.close();
}
});
}
private hideAuto() {
const time = parseInt(getClassProperty(this.overlay, '--auto-hide', '0'));
if (time) {
this.autoHide = setTimeout(() => {
this.close();
}, time);
}
}
private checkTimer() {
if (this.autoHide) {
clearTimeout(this.autoHide);
this.autoHide = null;
}
}
private buildBackdrop() {
const overlayClasses = this.overlay.classList.value.split(' ');
const overlayZIndex = parseInt(
window.getComputedStyle(this.overlay).getPropertyValue('z-index'),
);
const backdropId =
this.overlay.getAttribute('data-hs-overlay-backdrop-container') || false;
let backdrop: HTMLElement | Node = document.createElement('div');
let backdropClasses = `${this.backdropClasses} ${this.backdropExtraClasses}`;
const closeOnBackdrop =
getClassProperty(this.overlay, '--overlay-backdrop', 'true') !== 'static';
const disableBackdrop =
getClassProperty(this.overlay, '--overlay-backdrop', 'true') === 'false';
(backdrop as HTMLElement).id = `${this.overlay.id}-backdrop`;
if ('style' in backdrop) backdrop.style.zIndex = `${overlayZIndex - 1}`;
for (const value of overlayClasses) {
if (
value.startsWith('hs-overlay-backdrop-open:') ||
value.includes(':hs-overlay-backdrop-open:')
) {
backdropClasses += ` ${value}`;
}
}
if (disableBackdrop) return;
if (backdropId) {
backdrop = document.querySelector(backdropId).cloneNode(true);
(backdrop as HTMLElement).classList.remove('hidden');
backdropClasses = `${(backdrop as HTMLElement).classList.toString()}`;
(backdrop as HTMLElement).classList.value = '';
}
if (closeOnBackdrop) {
(backdrop as HTMLElement).addEventListener(
'click',
() => this.close(),
true,
);
}
(backdrop as HTMLElement).setAttribute(
'data-hs-overlay-backdrop-template',
'',
);
document.body.appendChild(backdrop);
setTimeout(() => {
(backdrop as HTMLElement).classList.value = backdropClasses;
});
}
private destroyBackdrop() {
const backdrop: HTMLElement = document.querySelector(
`#${this.overlay.id}-backdrop`,
);
if (!backdrop) return;
if (this.openNextOverlay) {
backdrop.style.transitionDuration = `${
parseFloat(
window
.getComputedStyle(backdrop)
.transitionDuration.replace(/[^\d.-]/g, ''),
) * 1.8
}s`;
}
backdrop.classList.add('opacity-0');
afterTransition(backdrop, () => {
backdrop.remove();
});
}
private focusElement() {
const input: HTMLInputElement = this.overlay.querySelector('[autofocus]');
if (!input) return false;
else input.focus();
}
private getScrollbarSize() {
let div = document.createElement('div');
div.style.overflow = 'scroll';
div.style.width = '100px';
div.style.height = '100px';
document.body.appendChild(div);
let scrollbarSize = div.offsetWidth - div.clientWidth;
document.body.removeChild(div);
return scrollbarSize;
}
// Public methods
public open() {
if (!this.overlay) return false;
const openedOverlays = document.querySelectorAll('.hs-overlay.open');
const currentlyOpened = window.$hsOverlayCollection.find(
(el) =>
Array.from(openedOverlays).includes(el.element.overlay) &&
!el.element.isLayoutAffect,
);
const toggles = document.querySelectorAll(
`[data-hs-overlay="#${this.overlay.id}"]`,
);
const disabledScroll =
getClassProperty(this.overlay, '--body-scroll', 'false') !== 'true';
if (this.isClosePrev && currentlyOpened) {
this.openNextOverlay = true;
return currentlyOpened.element.close().then(() => {
this.open();
this.openNextOverlay = false;
});
}
if (disabledScroll) {
document.body.style.overflow = 'hidden';
if (this.emulateScrollbarSpace)
document.body.style.paddingRight = `${this.getScrollbarSize()}px`;
}
this.buildBackdrop();
this.checkTimer();
this.hideAuto();
toggles.forEach((toggle) => {
if (toggle.ariaExpanded) toggle.ariaExpanded = 'true';
});
this.overlay.classList.remove(this.hiddenClass);
this.overlay.setAttribute('aria-overlay', 'true');
this.overlay.setAttribute('tabindex', '-1');
setTimeout(() => {
if (this.overlay.classList.contains('opened')) return false;
this.overlay.classList.add('open', 'opened');
if (this.isLayoutAffect)
document.body.classList.add('hs-overlay-body-open');
this.fireEvent('open', this.el);
dispatch('open.hs.overlay', this.el, this.el);
if (this.hasAutofocus) this.focusElement();
}, 50);
}
public close(forceClose = false) {
if (this.isLayoutAffect)
document.body.classList.remove('hs-overlay-body-open');
const closeFn = (cb: Function) => {
if (this.overlay.classList.contains('open')) return false;
const toggles = document.querySelectorAll(
`[data-hs-overlay="#${this.overlay.id}"]`,
);
toggles.forEach((toggle) => {
if (toggle.ariaExpanded) toggle.ariaExpanded = 'false';
});
this.overlay.classList.add(this.hiddenClass);
this.destroyBackdrop();
this.fireEvent('close', this.el);
dispatch('close.hs.overlay', this.el, this.el);
if (!document.querySelector('.hs-overlay.opened')) {
document.body.style.overflow = '';
if (this.emulateScrollbarSpace) document.body.style.paddingRight = '';
}
cb(this.overlay);
};
return new Promise((resolve) => {
if (!this.overlay) return false;
this.overlay.classList.remove('open', 'opened');
this.overlay.removeAttribute('aria-overlay');
this.overlay.removeAttribute('tabindex');
if (forceClose) closeFn(resolve);
else afterTransition(this.animationTarget, () => closeFn(resolve));
});
}
// Static methods
static getInstance(target: HTMLElement, isInstance?: boolean) {
const elInCollection = window.$hsOverlayCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string'
? document.querySelector(target)
: target) ||
el.element.overlay ===
(typeof target === 'string'
? document.querySelector(target)
: target),
);
return elInCollection
? isInstance
? elInCollection
: elInCollection.element.el
: null;
}
static autoInit() {
if (!window.$hsOverlayCollection) window.$hsOverlayCollection = [];
document
.querySelectorAll('[data-hs-overlay]:not(.--prevent-on-load-init)')
.forEach((el: HTMLElement) => {
if (
!window.$hsOverlayCollection.find(
(elC) => (elC?.element?.el as HTMLElement) === el,
)
)
new HSOverlay(el);
});
if (window.$hsOverlayCollection) {
document.addEventListener('keydown', (evt) =>
HSOverlay.accessibility(evt),
);
}
}
static open(target: HTMLElement) {
const elInCollection = window.$hsOverlayCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string'
? document.querySelector(target)
: target) ||
el.element.overlay ===
(typeof target === 'string'
? document.querySelector(target)
: target),
);
if (
elInCollection &&
elInCollection.element.overlay.classList.contains(
elInCollection.element.hiddenClass,
)
)
elInCollection.element.open();
}
static close(target: HTMLElement) {
const elInCollection = window.$hsOverlayCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string'
? document.querySelector(target)
: target) ||
el.element.overlay ===
(typeof target === 'string'
? document.querySelector(target)
: target),
);
if (
elInCollection &&
!elInCollection.element.overlay.classList.contains(
elInCollection.element.hiddenClass,
)
)
elInCollection.element.close();
}
static setOpened(breakpoint: number, el: ICollectionItem<HSOverlay>) {
if (document.body.clientWidth >= breakpoint) {
document.body.classList.add('hs-overlay-body-open');
el.element.overlay.classList.add('opened');
} else el.element.close(true);
}
// Accessibility methods
static accessibility(evt: KeyboardEvent) {
const targets = window.$hsOverlayCollection.filter((el) =>
el.element.overlay.classList.contains('open'),
);
const target = targets[targets.length - 1];
const focusableElements = target?.element?.overlay?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const notHiddenFocusableElements: HTMLElement[] = [];
if (focusableElements?.length)
focusableElements.forEach((el: HTMLElement) => {
if (!isParentOrElementHidden(el)) notHiddenFocusableElements.push(el);
});
const basicCheck = target && !evt.metaKey;
if (
basicCheck &&
!target.element.isTabAccessibilityLimited &&
evt.code === 'Tab'
)
return false;
if (basicCheck && notHiddenFocusableElements.length && evt.code === 'Tab') {
evt.preventDefault();
this.onTab(target, notHiddenFocusableElements);
}
if (basicCheck && evt.code === 'Escape') {
evt.preventDefault();
this.onEscape(target);
}
}
static onEscape(target: ICollectionItem<HSOverlay>) {
if (target && target.element.hasAbilityToCloseOnBackdropClick)
target.element.close();
}
static onTab(
target: ICollectionItem<HSOverlay>,
focusableElements: HTMLElement[],
) {
if (!focusableElements.length) return false;
const focused = target.element.overlay.querySelector(':focus');
const focusedIndex = Array.from(focusableElements).indexOf(
focused as HTMLElement,
);
if (focusedIndex > -1) {
const nextIndex = (focusedIndex + 1) % focusableElements.length;
(focusableElements[nextIndex] as HTMLElement).focus();
} else {
(focusableElements[0] as HTMLElement).focus();
}
}
// Backward compatibility
static on(evt: string, target: HTMLElement, cb: Function) {
const elInCollection = window.$hsOverlayCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string'
? document.querySelector(target)
: target) ||
el.element.overlay ===
(typeof target === 'string'
? document.querySelector(target)
: target),
);
if (elInCollection) elInCollection.element.events[evt] = cb;
}
}
declare global {
interface Window {
HSOverlay: Function;
$hsOverlayCollection: ICollectionItem<HSOverlay>[];
}
}
const autoCloseResizeFn = () => {
if (
!window.$hsOverlayCollection.length ||
!window.$hsOverlayCollection.find((el) => el.element.autoClose)
)
return false;
const overlays = window.$hsOverlayCollection.filter(
(el) => el.element.autoClose,
);
overlays.forEach((overlay) => {
if (document.body.clientWidth >= overlay.element.autoClose)
overlay.element.close(true);
});
};
const moveOverlayToBodyResizeFn = () => {
if (
!window.$hsOverlayCollection.length ||
!window.$hsOverlayCollection.find((el) => el.element.moveOverlayToBody)
)
return false;
const overlays = window.$hsOverlayCollection.filter(
(el) => el.element.moveOverlayToBody,
);
overlays.forEach((overlay) => {
const resolution = overlay.element.moveOverlayToBody;
const initPlace = overlay.element.initContainer;
const newPlace = document.querySelector('body');
const target = overlay.element.overlay;
if (!initPlace && target) return false;
if (
document.body.clientWidth <= resolution &&
!isDirectChild(newPlace, target)
)
newPlace.appendChild(target);
else if (
document.body.clientWidth > resolution &&
!initPlace.contains(target)
)
initPlace.appendChild(target);
});
};
const setOpenedResizeFn = () => {
if (
!window.$hsOverlayCollection.length ||
!window.$hsOverlayCollection.find((el) => el.element.autoClose)
)
return false;
const overlays = window.$hsOverlayCollection.filter(
(el) => el.element.autoClose,
);
overlays.forEach((overlay) => {
if (document.body.clientWidth >= overlay.element.autoClose)
overlay.element.close(true);
});
};
const setBackdropZIndexResizeFn = () => {
if (
!window.$hsOverlayCollection.length ||
!window.$hsOverlayCollection.find((el) =>
el.element.overlay.classList.contains('opened'),
)
)
return false;
const overlays = window.$hsOverlayCollection.filter((el) =>
el.element.overlay.classList.contains('opened'),
);
overlays.forEach((overlay) => {
const overlayZIndex = parseInt(
window
.getComputedStyle(overlay.element.overlay)
.getPropertyValue('z-index'),
);
const backdrop: HTMLElement = document.querySelector(
`#${overlay.element.overlay.id}-backdrop`,
);
const backdropZIndex = parseInt(
window.getComputedStyle(backdrop).getPropertyValue('z-index'),
);
if (overlayZIndex === backdropZIndex + 1) return false;
if ('style' in backdrop) backdrop.style.zIndex = `${overlayZIndex - 1}`;
document.body.classList.add('hs-overlay-body-open');
});
};
window.addEventListener('load', () => {
HSOverlay.autoInit();
moveOverlayToBodyResizeFn();
// Uncomment for debug
// console.log('Overlay collection:', window.$hsOverlayCollection);
});
window.addEventListener('resize', () => {
autoCloseResizeFn();
moveOverlayToBodyResizeFn();
setOpenedResizeFn();
setBackdropZIndexResizeFn();
});
if (typeof window !== 'undefined') {
window.HSOverlay = HSOverlay;
}
export default HSOverlay;