Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: use array of html struct instead of plain HTML Element #153

Merged
merged 1 commit into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 95 additions & 76 deletions lib/src/MultipleSelectInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
import Constants from './constants';
import { compareObjects, deepCopy, findByParam, removeDiacritics, removeUndefined, setDataKeys, stripScripts } from './utils';
import {
applyCssRules,
applyParsedStyleToElement,
calculateAvailableSpace,
convertItemRowToHtml,
convertStringStyleToElementStyle,
createDomElement,
emptyElement,
findParent,
Expand All @@ -17,7 +17,7 @@ import {
} from './utils/domUtils';
import type { HtmlElementPosition } from './utils/domUtils';
import type { MultipleSelectOption } from './interfaces/multipleSelectOption.interface';
import type { MultipleSelectLocales, OptGroupRowData, OptionDataObject, OptionRowData } from './interfaces';
import type { HtmlStruct, MultipleSelectLocales, OptGroupRowData, OptionDataObject, OptionRowData } from './interfaces';
import { BindingEventService, VirtualScroll } from './services';

export class MultipleSelectInstance {
Expand Down Expand Up @@ -421,7 +421,6 @@ export class MultipleSelectInstance {
this.dropElm
);
}

this.initListItems();
}

Expand Down Expand Up @@ -449,8 +448,8 @@ export class MultipleSelectInstance {
if (this.updateDataStart < 0) {
this.updateDataStart = 0;
}
if (this.updateDataEnd > (this.data?.length ?? 0)) {
this.updateDataEnd = this.data?.length ?? 0;
if (this.updateDataEnd > this.getDataLength()) {
this.updateDataEnd = this.getDataLength();
}
}
};
Expand All @@ -477,7 +476,7 @@ export class MultipleSelectInstance {
} else {
if (this.ulElm) {
emptyElement(this.ulElm);
rows.forEach((rowElm) => this.ulElm!.appendChild(rowElm));
rows.forEach((itemRow) => this.ulElm!.appendChild(convertItemRowToHtml(itemRow)));
}
this.updateDataStart = 0;
this.updateDataEnd = this.updateData.length;
Expand All @@ -486,17 +485,19 @@ export class MultipleSelectInstance {
this.events();
}

protected getListRows() {
const rows: HTMLElement[] = [];
protected getListRows(): HtmlStruct[] {
const rows: HtmlStruct[] = [];
this.updateData = [];
// console.time('perf');

this.data?.forEach((row) => rows.push(...this.initListItem(row)));
rows.push(createDomElement('li', { className: 'ms-no-results', textContent: this.formatNoMatchesFound() }));
rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound() } });
// console.timeEnd('perf');

return rows;
}

protected initListItem(row: any, level = 0): HTMLElement[] {
protected initListItem(row: OptionRowData | OptGroupRowData, level = 0): HtmlStruct[] {
const title = row?.title || '';
const multiple = this.options.multiple ? 'multiple' : '';
const type = this.options.single ? 'radio' : 'checkbox';
Expand All @@ -520,93 +521,104 @@ export class MultipleSelectInstance {
const customStyleRules = this.options.cssStyler(row);
const customStyle = this.options.styler(row);
const styleStr = String(customStyle || '');
const htmlElms: HTMLElement[] = [];
const groupElm =
const htmlBlocks: HtmlStruct[] = [];

const groupBlock =
this.options.hideOptgroupCheckboxes || this.options.single
? createDomElement('span', { dataset: { name: this.selectGroupName, key: row._key } })
: createDomElement('input', {
type: 'checkbox',
dataset: { name: this.selectGroupName, key: row._key },
ariaChecked: String(row.selected || false),
checked: row.selected,
disabled: row.disabled,
});
? { tagName: 'span', props: { dataset: { name: this.selectGroupName, key: row._key } } }
: {
tagName: 'input',
props: {
type: 'checkbox',
dataset: { name: this.selectGroupName, key: row._key },
ariaChecked: String(row.selected || false),
checked: Boolean(row.selected),
disabled: row.disabled,
},
};

if (!classes.includes('hide-radio') && (this.options.hideOptgroupCheckboxes || this.options.single)) {
classes += 'hide-radio ';
}

const labelElm = createDomElement('label', {
className: `optgroup${this.options.single || row.disabled ? ' disabled' : ''}`,
});
labelElm.appendChild(groupElm);
const spanLabelBlock: HtmlStruct = { tagName: 'span', props: {} };
this.applyAsTextOrHtmlWhenEnabled(spanLabelBlock.props, (row as OptGroupRowData).label);

const spanElm = document.createElement('span');
this.renderAsTextOrHtmlWhenEnabled(spanElm, row.label);
labelElm.appendChild(spanElm);
const liElm = createDomElement('li', { className: `group ${classes}`.trim() });
applyParsedStyleToElement(liElm, styleStr);
applyCssRules(liElm, customStyleRules);
liElm.appendChild(labelElm);
htmlElms.push(liElm);
const labelBlock: HtmlStruct = {
tagName: 'label',
props: { className: `optgroup${this.options.single || row.disabled ? ' disabled' : ''}` },
children: [groupBlock as HtmlStruct, spanLabelBlock],
};
const liBlock: HtmlStruct = { tagName: 'li', props: { className: `group ${classes}`.trim() }, children: [labelBlock] };
if (styleStr) {
liBlock.props.style = convertStringStyleToElementStyle(styleStr);
}
if (customStyleRules) {
liBlock.props.style = customStyleRules;
}
htmlBlocks.push(liBlock);

(row as OptGroupRowData).children.forEach((child: any) => {
htmlElms.push(...this.initListItem(child, 1));
htmlBlocks.push(...this.initListItem(child, 1));
});

return htmlElms;
return htmlBlocks;
}

const customStyleRules = this.options.cssStyler(row);
const customStyle = this.options.styler(row);
const style = String(customStyle || '');
const styleStr = String(customStyle || '');
classes += row.classes || '';

if (level && this.options.single) {
classes += `option-level-${level} `;
}

if (row.divider) {
return [createDomElement('li', { className: 'option-divider' })];
return [{ tagName: 'li', props: { className: 'option-divider' } } as HtmlStruct];
}

const liClasses = multiple || classes ? (multiple + classes).trim() : '';
const liElm = document.createElement('li');
if (liClasses) {
liElm.className = liClasses;
}
if (title) {
liElm.title = title;
}

applyParsedStyleToElement(liElm, style);
applyCssRules(liElm, customStyleRules);
const labelClasses = `${row.disabled ? 'disabled' : ''}`;
const labelElm = document.createElement('label');
if (labelClasses) {
labelElm.className = labelClasses;
}
const spanLabelBlock: HtmlStruct = { tagName: 'span', props: {} };
this.applyAsTextOrHtmlWhenEnabled(spanLabelBlock.props, (row as OptionRowData).text);

const inputBlock: HtmlStruct = {
tagName: 'input',
props: {
type,
value: encodeURI(row.value as string),
dataset: { key: row._key, name: this.selectItemName },
ariaChecked: String(row.selected || false),
checked: Boolean(row.selected),
disabled: Boolean(row.disabled),
},
};

const inputElm = createDomElement('input', {
type,
value: encodeURI(row.value),
dataset: { key: row._key, name: this.selectItemName },
ariaChecked: String(row.selected || false),
checked: Boolean(row.selected),
disabled: Boolean(row.disabled),
});
if (row.selected) {
inputElm.setAttribute('checked', 'checked');
inputBlock.attrs = { checked: 'checked' };
}

const spanElm = document.createElement('span');
this.renderAsTextOrHtmlWhenEnabled(spanElm, row.text);
const labelBlock: HtmlStruct = { tagName: 'label', props: {}, children: [inputBlock, spanLabelBlock] };
if (labelClasses) {
labelBlock.props.className = labelClasses;
}

labelElm.appendChild(inputElm);
labelElm.appendChild(spanElm);
liElm.appendChild(labelElm);
const liBlock: HtmlStruct = { tagName: 'li', props: {}, children: [labelBlock] };
if (liClasses) {
liBlock.props.className = liClasses;
}
if (title) {
liBlock.props.title = title;
}
if (styleStr) {
liBlock.props.style = convertStringStyleToElementStyle(styleStr);
}
if (customStyleRules) {
liBlock.props.style = customStyleRules;
}

return [liElm];
return [liBlock];
}

protected initSelected(ignoreTrigger = false) {
Expand Down Expand Up @@ -660,7 +672,6 @@ export class MultipleSelectInstance {
}

this.parentElm.style.width = `${this.options.width || computedWidth}px`;
// this.elm.style.display = 'inline-block';
this.elm.classList.add('ms-offscreen');
}

Expand Down Expand Up @@ -877,7 +888,7 @@ export class MultipleSelectInstance {
this.noResultsElm.style.display = 'none';
}

if (!this.data?.length) {
if (!this.getDataLength()) {
if (this.selectAllElm?.parentElement) {
this.selectAllElm.parentElement.style.display = 'none';
}
Expand Down Expand Up @@ -918,7 +929,7 @@ export class MultipleSelectInstance {
const multElms = this.dropElm.querySelectorAll<HTMLDivElement>('.multiple');
multElms.forEach((multElm) => (multElm.style.width = `${this.options.multipleWidth}px`));

if (this.data?.length && this.options.filter) {
if (this.getDataLength() && this.options.filter) {
if (this.searchInputElm) {
this.searchInputElm.value = '';
this.searchInputElm.focus();
Expand Down Expand Up @@ -966,15 +977,19 @@ export class MultipleSelectInstance {
}

/**
* Renders value to an HTML element as text or as HTML with innerHTML when enabled
* apply value to an HTML element as text or as HTML with innerHTML when enabled
* @param elm
* @param value
*/
protected renderAsTextOrHtmlWhenEnabled(elm: HTMLElement, value: string) {
protected applyAsTextOrHtmlWhenEnabled(elmOrProp: HTMLElement | any, value: string) {
if (!elmOrProp) {
elmOrProp = {};
}
if (this.isRenderAsHtml) {
elm.innerHTML = (typeof this.options.sanitizer === 'function' ? this.options.sanitizer(value) : value) as unknown as string;
// prettier-ignore
elmOrProp.innerHTML = (typeof this.options.sanitizer === 'function' ? this.options.sanitizer(value) : value) as unknown as string;
} else {
elm.textContent = value;
elmOrProp.textContent = value;
}
}

Expand Down Expand Up @@ -1002,7 +1017,7 @@ export class MultipleSelectInstance {
if (sl === 0) {
const placeholder = this.options.placeholder || '';
spanElm.classList.add('ms-placeholder');
this.renderAsTextOrHtmlWhenEnabled(spanElm, placeholder);
this.applyAsTextOrHtmlWhenEnabled(spanElm, placeholder);
} else if (sl < this.options.minimumCountSelected) {
html = getSelectOptionHtml();
} else if (this.formatAllSelected() && sl === this.dataTotal) {
Expand All @@ -1017,7 +1032,7 @@ export class MultipleSelectInstance {

if (html !== null) {
spanElm?.classList.remove('ms-placeholder');
this.renderAsTextOrHtmlWhenEnabled(spanElm, html);
this.applyAsTextOrHtmlWhenEnabled(spanElm, html);
}

if (this.options.displayTitle || this.options.addTitle) {
Expand Down Expand Up @@ -1081,6 +1096,10 @@ export class MultipleSelectInstance {
return this.options.data;
}

getDataLength() {
return this.data?.length ?? 0;
}

/**
* Get current options, by default we'll return an immutable deep copy of the options to avoid conflicting with the lib
* but in rare occasion we might want to the return the actual, but mutable, options
Expand Down Expand Up @@ -1150,7 +1169,7 @@ export class MultipleSelectInstance {
let selected = false;
if (type === 'text') {
const divElm = document.createElement('div');
this.renderAsTextOrHtmlWhenEnabled(divElm, row.text);
this.applyAsTextOrHtmlWhenEnabled(divElm, row.text);
selected = values.includes(divElm.textContent?.trim() ?? '');
} else {
selected = values.includes(row._value || row.value);
Expand Down
22 changes: 21 additions & 1 deletion lib/src/interfaces/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
/* eslint-disable @typescript-eslint/indent */
export type InferDOMType<T> = T extends CSSStyleDeclaration ? Partial<CSSStyleDeclaration> : T extends infer R ? R : any;
/* eslint-enable @typescript-eslint/indent */

export type OptionDataObject = { [value: string]: number | string | boolean };

export interface OptionRowDivider {
divider: boolean;
}

export interface HtmlStruct {
tagName: keyof HTMLElementTagNameMap;
props?: any;
attrs?: Record<any, string>;
children?: HtmlStruct[];
}

export interface OptionRowData {
text: string;
value: string | number | boolean;
classes?: string;
divider?: string;
disabled?: boolean;
selected?: boolean | number;
visible?: boolean | string;
title?: string;
type?: 'option' | 'optgroup';
_key?: string;
_value?: string | number | boolean;
Expand All @@ -22,8 +35,15 @@ export interface OptGroupRowData extends Omit<OptionRowData, 'text' | 'value'> {
children: Array<OptionRowData>;
}

export interface VirtualCache {
bottom: number;
data: HtmlStruct[];
scrollTop: number;
top: number;
}

export interface VirtualScrollOption {
rows: HTMLElement[];
rows: HtmlStruct[];
scrollEl: HTMLElement;
contentEl: HTMLElement;
callback: () => void;
Expand Down
Loading