src/helper/dom.ts (356 lines of code) (raw):
/*!
* Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import testIds from '../helper/test-ids';
import { MynahEventNames, MynahPortalNames } from '../static';
import { Config } from './config';
import { MynahUIGlobalEvents } from './events';
import { AllowedTagsInCustomRenderer, AllowedAttributesInCustomRenderer } from './sanitize';
/* eslint-disable @typescript-eslint/method-signature-style */
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
export const DS: typeof document.querySelectorAll = document.querySelectorAll.bind(document);
export type GenericEvents = Extract<keyof GlobalEventHandlersEventMap, string>;
export type DomBuilderEventHandler = (event?: any) => any;
export interface DomBuilderEventHandlerWithOptions {
handler: DomBuilderEventHandler;
options?: AddEventListenerOptions;
}
interface GenericDomBuilderAttributes {
attributes?: Record<string, string | boolean> | undefined;
classNames?: string[] | undefined;
events?: Partial<Record<GenericEvents, DomBuilderEventHandler | DomBuilderEventHandlerWithOptions>> | undefined;
}
export interface ChatItemBodyRenderer extends GenericDomBuilderAttributes {
type: AllowedTagsInCustomRenderer;
children?: Array<string | ChatItemBodyRenderer> | undefined;
attributes?: Partial<Record<AllowedAttributesInCustomRenderer, string>> | undefined;
}
export interface DomBuilderObject extends GenericDomBuilderAttributes{
type: string;
children?: Array<string | DomBuilderObject | HTMLElement | ExtendedHTMLElement> | undefined;
innerHTML?: string | undefined;
testId?: string;
persistent?: boolean | undefined;
}
export interface DomBuilderObjectFilled {
attributes?: Record<string, string | undefined>;
classNames?: string[];
events?: Record<string, (event?: any) => any>;
children?: Array<string | DomBuilderObject | HTMLElement | ExtendedHTMLElement>;
innerHTML?: string | undefined;
testId?: string;
persistent?: boolean;
}
const EmptyDomBuilderObject: DomBuilderObject = {
type: 'div',
attributes: {},
classNames: [],
events: {},
children: [],
innerHTML: undefined,
persistent: false,
};
export interface ExtendedHTMLElement extends HTMLInputElement {
addClass(className: string): ExtendedHTMLElement;
removeClass(className: string): ExtendedHTMLElement;
toggleClass(className: string): ExtendedHTMLElement;
hasClass(className: string): boolean;
insertChild(
position: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend',
child: string | DomBuilderObject | HTMLElement | ExtendedHTMLElement | Array<string | DomBuilderObject | HTMLElement | ExtendedHTMLElement>
): ExtendedHTMLElement;
clear(removePersistent?: boolean): ExtendedHTMLElement;
builderObject: DomBuilderObject;
update(builderObject: DomBuilderObjectFilled): ExtendedHTMLElement;
}
export class DomBuilder {
private static instance: DomBuilder | undefined;
private rootFocus: boolean;
private readonly resizeObserver: ResizeObserver;
private rootBox: DOMRect;
root: ExtendedHTMLElement;
private portals: Record<string, ExtendedHTMLElement> = {};
private constructor (rootSelector: string) {
this.root = DS(rootSelector)[0] as ExtendedHTMLElement;
this.extendDomFunctionality(this.root);
this.root.addClass('mynah-ui-root');
this.rootFocus = this.root.matches(':focus') ?? false;
this.attachRootFocusListeners();
if (ResizeObserver != null) {
this.rootBox = this.root.getBoundingClientRect();
this.resizeObserver = new ResizeObserver((entry) => {
const incomingRootBox = this.root.getBoundingClientRect();
// Known issue of ResizeObserver, triggers twice for each size change.
// Check if size was really changed then trigger
if (this.rootBox.height !== incomingRootBox.height || this.rootBox.width !== incomingRootBox.width) {
this.rootBox = incomingRootBox;
MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.ROOT_RESIZE, { clientRect: this.rootBox });
}
});
this.resizeObserver.observe(this.root);
}
}
private readonly attachRootFocusListeners = (): void => {
this.root?.setAttribute('tabindex', '0');
this.root?.setAttribute('autofocus', 'true');
this.root?.style.setProperty('outline', 'none');
this.root?.addEventListener('focusin', this.onRootFocus, { capture: true });
window.addEventListener('blur', this.onRootBlur);
};
private readonly onRootFocus = (e: FocusEvent): void => {
if (!this.rootFocus) {
this.rootFocus = true;
MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.ROOT_FOCUS, { focusState: this.rootFocus });
}
};
private readonly onRootBlur = (e: FocusEvent): void => {
if (this.rootFocus) {
this.rootFocus = false;
MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.ROOT_FOCUS, { focusState: this.rootFocus });
}
};
public readonly setFocusToRoot = (): void => {
this.root?.focus();
};
public static getInstance (rootSelector?: string): DomBuilder {
if (!DomBuilder.instance) {
DomBuilder.instance = new DomBuilder(rootSelector != null ? rootSelector : 'body');
}
if (rootSelector != null) {
DomBuilder.instance.setRoot(rootSelector);
}
return DomBuilder.instance;
}
setRoot = (rootSelector?: string): void => {
this.resizeObserver.unobserve(this.root);
this.root.removeEventListener('focus', this.onRootFocus);
window.removeEventListener('blur', this.onRootBlur);
this.root = this.extendDomFunctionality((DS(rootSelector ?? 'body')[0] ?? document.body) as HTMLElement);
this.attachRootFocusListeners();
this.resizeObserver.observe(this.root);
};
addClass = function (this: ExtendedHTMLElement, className: string): ExtendedHTMLElement {
if (className !== '') {
this.classList.add(className);
// eslint-disable-next-line @typescript-eslint/prefer-includes
if (this.builderObject?.classNames?.indexOf(className) === -1) {
this.builderObject.classNames = [ ...this.builderObject.classNames, className ];
}
}
return this;
};
removeClass = function (this: ExtendedHTMLElement, className: string): ExtendedHTMLElement {
this.classList.remove(className);
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (this.builderObject.classNames !== undefined && this.builderObject.classNames.includes(className)) {
this.builderObject.classNames.splice(this.builderObject.classNames.indexOf(className), 1);
}
return this;
};
toggleClass = function (this: ExtendedHTMLElement, className: string): ExtendedHTMLElement {
if (this.classList.contains(className)) {
this.removeClass(className);
} else {
this.addClass(className);
}
return this;
};
hasClass = function (this: ExtendedHTMLElement, className: string): boolean {
return this.classList.contains(className);
};
insertChild = function (
this: ExtendedHTMLElement,
position: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend',
child: string | HTMLElement | ExtendedHTMLElement | Array<string | HTMLElement | ExtendedHTMLElement>
): ExtendedHTMLElement {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (child) {
if (child instanceof Array) {
child.forEach(childItem => {
if (childItem instanceof Element) {
this.insertAdjacentElement(position, childItem);
} else if (typeof childItem === 'string') {
this.insertAdjacentText(position, childItem);
}
});
} else {
if (child instanceof Element) {
this.insertAdjacentElement(position, child);
} else if (typeof child === 'string') {
this.insertAdjacentText(position, child);
}
}
}
return this;
};
clearChildren = function (this: ExtendedHTMLElement, removePersistent: boolean): ExtendedHTMLElement {
Array.from(this.childNodes).forEach((child: ExtendedHTMLElement | ChildNode) => {
if (
removePersistent ||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
!(child as ExtendedHTMLElement).builderObject ||
(child as ExtendedHTMLElement).builderObject.persistent !== true
) {
child.remove();
}
});
return this;
};
extendDomFunctionality = function (this: DomBuilder, domElement: HTMLElement): ExtendedHTMLElement {
const extendedElement: ExtendedHTMLElement = domElement as ExtendedHTMLElement;
extendedElement.addClass = this.addClass.bind(extendedElement);
extendedElement.removeClass = this.removeClass.bind(extendedElement);
extendedElement.toggleClass = this.toggleClass.bind(extendedElement);
extendedElement.hasClass = this.hasClass.bind(extendedElement);
extendedElement.insertChild = this.insertChild.bind(extendedElement);
extendedElement.clear = this.clearChildren.bind(extendedElement);
return extendedElement;
};
build = (domBuilderObject: DomBuilderObject): ExtendedHTMLElement => {
const readyToBuildObject: DomBuilderObject = { ...EmptyDomBuilderObject, ...domBuilderObject };
const buildedDom = document.createElement(readyToBuildObject.type);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
buildedDom.classList.add(...(readyToBuildObject.classNames?.filter(className => className !== '') || []));
(Object.keys(readyToBuildObject.events ?? {}) as Array<Partial<GenericEvents>>).forEach((eventName: GenericEvents) => {
if (readyToBuildObject?.events !== undefined) {
if (typeof readyToBuildObject?.events[eventName] === 'function') {
buildedDom.addEventListener(eventName, readyToBuildObject.events[eventName] as (event?: any) => any);
} else if (typeof readyToBuildObject?.events[eventName] === 'object') {
buildedDom.addEventListener(
eventName,
(readyToBuildObject.events[eventName] as DomBuilderEventHandlerWithOptions).handler,
(readyToBuildObject.events[eventName] as DomBuilderEventHandlerWithOptions).options ?? undefined
);
}
if (eventName === 'dblclick' || eventName === 'click') {
buildedDom.classList.add('mynah-ui-clickable-item');
}
}
});
Object.keys(readyToBuildObject.attributes ?? {}).forEach(attributeName =>
buildedDom.setAttribute(attributeName, readyToBuildObject.attributes !== undefined ? readyToBuildObject.attributes[attributeName].toString() : '')
);
if (readyToBuildObject.testId != null && Config.getInstance().config.test) {
buildedDom.setAttribute(testIds.selector, readyToBuildObject.testId);
}
if (typeof readyToBuildObject.innerHTML === 'string') {
buildedDom.innerHTML = readyToBuildObject.innerHTML;
} else if (readyToBuildObject.children !== undefined && readyToBuildObject.children?.length > 0) {
this.insertChild.apply(buildedDom as ExtendedHTMLElement, [
'beforeend',
[
...readyToBuildObject.children.map((child: string | ExtendedHTMLElement | HTMLElement | DomBuilderObject) => {
if (typeof child === 'string' || child instanceof HTMLElement) {
return child;
}
return this.build(child);
}),
],
]);
}
(buildedDom as ExtendedHTMLElement).builderObject = readyToBuildObject;
(buildedDom as ExtendedHTMLElement).update = (builderObject: DomBuilderObjectFilled): ExtendedHTMLElement => {
return this.update(buildedDom as ExtendedHTMLElement, builderObject);
};
this.extendDomFunctionality(buildedDom);
return buildedDom as ExtendedHTMLElement;
};
update = function (domToUpdate: ExtendedHTMLElement, domBuilderObject: DomBuilderObjectFilled): ExtendedHTMLElement {
if (domToUpdate.builderObject) {
if (domBuilderObject.classNames !== undefined) {
domToUpdate.classList.remove(...(domToUpdate.builderObject.classNames as string[]));
domToUpdate.classList.add(...domBuilderObject.classNames.filter(className => className !== ''));
}
(Object.keys(domBuilderObject.events ?? {}) as Array<Partial<GenericEvents>>).forEach(eventName => {
if (domToUpdate.builderObject.events !== undefined && domToUpdate.builderObject.events[eventName]) {
domToUpdate.removeEventListener(
eventName,
(domToUpdate.builderObject.events[eventName] as DomBuilderEventHandlerWithOptions).handler ?? domToUpdate.builderObject.events[eventName]
);
}
if (domBuilderObject.events !== undefined && domBuilderObject.events[eventName] !== undefined) {
domToUpdate.addEventListener(eventName, domBuilderObject.events[eventName]);
}
});
Object.keys(domBuilderObject.attributes ?? {}).forEach(attributeName => {
if (domBuilderObject.attributes !== undefined && domBuilderObject.attributes[attributeName] === undefined) {
domToUpdate.removeAttribute(attributeName);
} else if (domBuilderObject.attributes !== undefined) {
domToUpdate.setAttribute(attributeName, domBuilderObject.attributes[attributeName] as string);
}
});
if (domBuilderObject.testId != null && Config.getInstance().config.test) {
domToUpdate.setAttribute(testIds.selector, domBuilderObject.testId);
}
if (typeof domBuilderObject.innerHTML === 'string') {
domToUpdate.innerHTML = domBuilderObject.innerHTML;
} else if (domBuilderObject.children !== undefined && domBuilderObject.children.length > 0) {
domToUpdate.clear();
domToUpdate.insertChild('beforeend', domBuilderObject.children);
}
domToUpdate.builderObject = { ...EmptyDomBuilderObject, ...domBuilderObject } as DomBuilderObject;
} else {
console.warn('element was not created with dom builder');
}
return domToUpdate;
};
createPortal = (
portalName: string,
builderObject: DomBuilderObject,
position: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'
): ExtendedHTMLElement => {
const portalDom = this.build(builderObject);
this.root.insertChild(position || 'beforeend', portalDom);
this.portals[portalName] = portalDom;
return portalDom;
};
getPortal = (portalName: string): ExtendedHTMLElement | undefined => this.portals[portalName] ?? undefined;
removePortal = (portalName: string): void => this.portals[portalName]?.remove();
removeAllPortals = (portalsWithName: MynahPortalNames): void => {
Object.keys(this.portals).forEach(portalName => {
if (portalName.match(portalsWithName) !== null) {
this.portals[portalName].remove();
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.portals[portalName];
}
});
};
destroy = (): void => {
DomBuilder.instance = undefined;
};
}
export const htmlDecode = (input: string): string => {
const e = document.createElement('textarea');
e.innerHTML = input;
return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue ?? input;
};
export const getTypewriterPartsCss = (
typewriterId: string,
lastVisibleItemIndex: number,
totalNumberOfItems: number,
timeForEach: number): ExtendedHTMLElement =>
DomBuilder.getInstance().build({
type: 'style',
attributes: {
type: 'text/css'
},
persistent: true,
innerHTML: `
${new Array(Math.max(0, totalNumberOfItems))
.fill(null)
.map((n, i) => {
if (i < lastVisibleItemIndex) {
return `
.${typewriterId} .typewriter-part[index="${i}"] {
opacity: 1 !important;
animation: none;
}
`;
}
return `
.${typewriterId} .typewriter-part[index="${i}"] {
opacity: 0;
animation: typewriter-reveal ${50 + timeForEach}ms linear forwards;
animation-delay: ${(i - lastVisibleItemIndex) * timeForEach}ms;
}
`;
})
.join('')}
`,
});
export const cleanupElement = (elm: HTMLElement): void => {
if (elm.querySelectorAll !== undefined) {
Array.from(elm.querySelectorAll('*:empty:not(img, br, hr, input[type="checkbox"])')).forEach(emptyElement => {
if (emptyElement.classList.length === 0) {
emptyElement.remove();
}
});
}
};