multiple-select-vanilla
Version:
This lib allows you to select multiple elements with checkboxes
273 lines (241 loc) • 9.23 kB
text/typescript
import type { HtmlStruct, InferDOMType } from '../models/interfaces.js';
import { objectRemoveEmptyProps } from './utils.js';
export interface HtmlElementPosition {
top: number;
bottom: number;
left: number;
right: number;
}
/** calculate available space for each side of the DOM element */
export function calculateAvailableSpace(element: HTMLElement): { top: number; bottom: number; left: number; right: number } {
let bottom = 0;
let top = 0;
let left = 0;
let right = 0;
const windowHeight = window.innerHeight ?? 0;
const windowWidth = window.innerWidth ?? 0;
const scrollPosition = windowScrollPosition();
const pageScrollTop = scrollPosition.top;
const pageScrollLeft = scrollPosition.left;
const elmOffset = getElementOffset(element);
if (elmOffset) {
const elementOffsetTop = elmOffset.top ?? 0;
const elementOffsetLeft = elmOffset.left ?? 0;
top = elementOffsetTop - pageScrollTop;
bottom = windowHeight - (elementOffsetTop - pageScrollTop);
left = elementOffsetLeft - pageScrollLeft;
right = windowWidth - (elementOffsetLeft - pageScrollLeft);
}
return { top, bottom, left, right };
}
/**
* Accepts string containing the class or space-separated list of classes, and
* returns list of individual classes.
* Method properly takes into account extra whitespaces in the `className`
* e.g.: " class1 class2 " => will result in `['class1', 'class2']`.
* @param {String} className - space separated list of class names
*/
export function classNameToList(className = ''): string[] {
return className.split(' ').filter(cls => cls); // filter will remove whitespace entries
}
/**
* Create a DOM Element with any optional attributes or properties.
* It will only accept valid DOM element properties that `createElement` would accept.
* For example: `createDomElement('div', { className: 'my-css-class' })`,
* for style or dataset you need to use nested object `{ style: { display: 'none' }}
* The last argument is to optionally append the created element to a parent container element.
* @param {String} tagName - html tag
* @param {Object} options - element properties
* @param {[HTMLElement]} appendToParent - parent element to append to
*/
export function createDomElement<T extends keyof HTMLElementTagNameMap, K extends keyof HTMLElementTagNameMap[T]>(
tagName: T,
elementOptions?: { [P in K]: InferDOMType<HTMLElementTagNameMap[T][P]> },
appendToParent?: HTMLElement,
): HTMLElementTagNameMap[T] {
const elm = document.createElement<T>(tagName);
if (elementOptions) {
Object.keys(elementOptions).forEach(elmOptionKey => {
const elmValue = elementOptions[elmOptionKey as keyof typeof elementOptions];
if (typeof elmValue === 'object') {
Object.assign(elm[elmOptionKey as K] as object, elmValue);
} else {
elm[elmOptionKey as K] = (elementOptions as any)[elmOptionKey as keyof typeof elementOptions];
}
});
}
if (appendToParent?.appendChild) {
appendToParent.appendChild(elm);
}
return elm;
}
/**
* From an HtmlBlock, create the DOM structure and append it to dedicated DOM element, for example:
* `{ tagName: 'li', props: { className: 'some-class' }, attrs: { 'aria-label': 'some label' }, children: [] }`
* @param item
* @param appendToElm
*/
export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement, parentElm?: HTMLElement): HTMLElement {
// to be CSP safe, we'll omit `innerHTML` and assign it manually afterward
const itemPropsOmitHtml = item.props?.innerHTML ? omitProp(item.props, 'innerHTML') : item.props;
const elm = createDomElement(item.tagName, objectRemoveEmptyProps(itemPropsOmitHtml, ['className', 'title', 'style']), appendToElm);
let parent: HTMLElement | null | undefined = parentElm;
if (!parent) {
parent = elm;
}
if (item.props.innerHTML) {
elm.innerHTML = item.props.innerHTML; // at this point, string type should already be as TrustedHTML
}
// add all custom DOM element attributes
if (item.attrs) {
for (const attrName of Object.keys(item.attrs)) {
elm.setAttribute(attrName, item.attrs[attrName]);
}
}
// use recursion when finding item children
if (item.children) {
for (const childItem of item.children) {
createDomStructure(childItem, elm, parent);
}
}
appendToElm?.appendChild(elm);
return elm;
}
/** takes an html block object and converts to a real HTMLElement */
export function convertItemRowToHtml(item: HtmlStruct): HTMLElement {
if (item.hasOwnProperty('tagName')) {
return createDomStructure(item);
}
return document.createElement('li');
}
/**
* Empty a DOM element by removing all of its DOM element children leaving with an empty element (basically an empty shell)
* @return {object} element - updated element
*/
export function emptyElement<T extends Element = Element>(element?: T | null): T | undefined | null {
while (element?.firstChild) {
if (element.lastChild) {
element.removeChild(element.lastChild);
}
}
return element;
}
/** Get HTML element offset with pure JS */
export function getElementOffset(element?: HTMLElement): HtmlElementPosition | undefined {
if (!element) {
return undefined;
}
const rect = element?.getBoundingClientRect?.();
let top = 0;
let left = 0;
let bottom = 0;
let right = 0;
if (rect?.top !== undefined && rect.left !== undefined) {
top = rect.top + window.pageYOffset;
left = rect.left + window.pageXOffset;
right = rect.right;
bottom = rect.bottom;
}
return { top, left, bottom, right };
}
export function getElementSize(elm: HTMLElement | undefined, mode: 'inner' | 'outer' | 'scroll', type: 'height' | 'width') {
if (!elm) {
return 0;
}
// first try defined style width or offsetWidth (which include scroll & padding)
let size = Number.parseFloat(elm.style[type]);
if (!size || Number.isNaN(size)) {
switch (mode) {
case 'outer':
size = elm[type === 'width' ? 'offsetWidth' : 'offsetHeight'];
break;
case 'scroll':
size = elm[type === 'width' ? 'scrollWidth' : 'scrollHeight'];
break;
case 'inner':
default:
size = elm[type === 'width' ? 'clientWidth' : 'clientHeight'];
break;
}
size = elm.getBoundingClientRect()[type];
}
if (!size || Number.isNaN(size)) {
// when 0 width, we'll try different ways
// when element is auto or 0, we'll keep previous style values to get width and then reapply original values
const prevDisplay = elm.style.display;
const prevPosition = elm.style.position;
elm.style.display = 'block';
elm.style.position = 'absolute';
const widthStr = window.getComputedStyle(elm)[type];
size = Number.parseFloat(widthStr);
if (Number.isNaN(size)) {
size = 0;
}
// reapply original values
elm.style.display = prevDisplay;
elm.style.position = prevPosition;
}
return size || 0;
}
/**
* Find a single parent by a simple selector, it only works with a simple selector
* for example: "input.some-class", ".some-class", "input#some-id"
* Note: it won't work with complex selector like "div.some-class input.my-class"
* @param elm
* @param selector
* @returns
*/
export function findParent(elm: HTMLElement, selector: string) {
let targetElm: HTMLElement | null = null;
let parentElm = elm?.parentElement;
while (parentElm) {
// query selector id (#some-id) or class (.some-class other-class)
const [_, nodeType, selectorType, classOrIdName] = selector.match(/^([a-z]*)([#.]{1})([a-z\-]+)$/i) || [];
if (selectorType && classOrIdName) {
// class or id selector type
for (const q of classOrIdName.replace(selectorType, '').split(' ')) {
if (parentElm.classList.contains(q)) {
if (nodeType) {
if (parentElm?.tagName.toLowerCase() === nodeType) {
targetElm = parentElm;
}
} else {
targetElm = parentElm;
}
}
}
}
parentElm = parentElm.parentElement;
}
return targetElm;
}
export function insertAfter(referenceNode: HTMLElement, newNode: HTMLElement) {
referenceNode.parentNode?.insertBefore(newNode, referenceNode.nextSibling);
}
export function omitProp(obj: any, key: string) {
const { [key]: omitted, ...rest } = obj;
return rest;
}
/** Display or hide matched element */
export function toggleElement(elm?: HTMLElement | null, display?: boolean) {
if (elm?.style) {
elm.style.display = (elm.style.display === 'none' && display !== false) || display === true ? 'block' : 'none';
}
}
export function toggleElementClass(elm?: HTMLElement | null, state?: boolean) {
if (elm?.classList) {
const adding = state === true || !elm.classList.contains('selected');
const action = adding ? 'add' : 'remove';
elm.classList[action]('selected');
}
}
/**
* Get the Window Scroll top/left Position
* @returns
*/
export function windowScrollPosition(): { left: number; top: number } {
return {
left: window.pageXOffset || document.documentElement.scrollLeft || 0,
top: window.pageYOffset || document.documentElement.scrollTop || 0,
};
}