multiple-select-vanilla
Version:
This lib allows you to select multiple elements with checkboxes
274 lines (244 loc) • 9.48 kB
text/typescript
import type { HtmlStruct, InferDOMType } from '../models/interfaces.js';
import { isDefined, 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(elm: HTMLElement): { top: number; bottom: number; left: number; right: number } {
const { innerHeight: windowHeight = 0, innerWidth: windowWidth = 0 } = window;
const { top: pageScrollTop, left: pageScrollLeft } = windowScrollPosition();
const { top = 0, left = 0 } = getOffset(elm) || {};
return {
top: top - pageScrollTop,
bottom: windowHeight - (top - pageScrollTop),
left: left - pageScrollLeft,
right: windowWidth - (left - pageScrollLeft),
};
}
/**
* 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];
}
});
}
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>(elm?: T | null): T | undefined | null {
while (elm?.firstChild) {
if (elm.lastChild) {
elm.removeChild(elm.lastChild);
}
}
return elm;
}
/** Get HTML element offset with pure JS */
export function getOffset(element?: HTMLElement): HtmlElementPosition | undefined {
if (element) {
const { top, left, bottom, right } = element.getBoundingClientRect();
return {
top: top + window.pageYOffset,
left: left + window.pageXOffset,
bottom,
right,
};
}
return undefined;
}
export function getSize(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)) {
const pascalType = type === 'height' ? 'Height' : 'Width';
switch (mode) {
case 'outer':
size = elm[`offset${pascalType}`];
break;
case 'scroll':
size = elm[`scroll${pascalType}`];
break;
case 'inner':
default:
size = elm[`client${pascalType}`];
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';
size = getComputedSize(elm, type);
// reapply original values
elm.style.display = prevDisplay;
elm.style.position = prevPosition;
}
return size || 0;
}
/**
* use `getComputedStyle()` and return size has a number or default to 0
* @param elm - HTML element
* @param styleType - CSS style type (e.g. 'width', 'paddingLeft', ...)
* @returns - return size as a number or 0 when unparseable
*/
export function getComputedSize(elm: HTMLElement, styleType: string) {
const elmStyle = window.getComputedStyle(elm)[styleType as any];
let size = Number.parseFloat(elmStyle);
if (Number.isNaN(size)) {
size = 0;
}
return size;
}
/**
* 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 - HTML element
* @param selector - HTML 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;
}
/**
* Simple function to decode the most common HTML entities.
* For example: "<div>Hablar español? ��</div>" => "<div>Hablar español? 🦄</div>"
* @param {String} inputValue - input value to be decoded
* @return {String}
*/
export function htmlDecode(input?: string | boolean | number): string {
if (isDefined(input)) {
// 1. decode html entities (e.g. `'` => single quote)
// 2. use textarea to decode the rest (e.g. html tags and symbols, `<div>` => `<div>`)
const txt = document.createElement('textarea');
txt.innerHTML = input.toString().replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec));
return txt.value;
}
return '';
}
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';
}
}
/**
* 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,
};
}